diff --git a/CLAUDE.md b/CLAUDE.md index 9d0c226ce..e066dd9aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,7 +158,7 @@ Repo-level facts that are NOT in any skill — they live here on purpose: git grep -lE 'MIN_HEADROOM|st_size <=' -- scripts/ # ⚠ union, and NOT provably complete ``` ⚠ It over-matches too (`dl-router/server.py`, `present/measure.py`, `skill-audit.py` are not doc ceilings), so read each hit rather than counting them. This bullet has now been wrong THREE times: twice carrying a count that was stale within a day, and once — for longer, because nobody re-ran it — carrying a discovery method that silently returned an incomplete set. A grep quoted as authoritative is a claim like any other; the only fix that would stop this recurring is a test enumerating ceilinged docs two-way, which does not exist yet. Any addition needs an eviction in the SAME commit; raising a ceiling needs the commit message to say which instruction would not fit. -- **Run the gate with `scripts/gate.sh`** (`--tier pytest|node|both`, `--set hermetic|all`). It sends the full output to a LOG FILE and prints only a bounded summary, so there is no reason to pipe it — and **its exit status is authoritative**. It also cross-checks that status against the runners' own `RESULT:` line and exits **90 = could-not-vouch** when they disagree, when a run printed no verdict, or when `panic: test timed out` appears. 90 is not "the tests failed"; it means read the log. 🔴 It also exits **91 = PARTIAL** when every tier passed but one did not report `SCOPE: FULL` — a narrowed run, or one that printed no scope at all. Neither 90 nor 91 is a gate PASS. It refuses outright (exit 2) if any of `DEVRC_TARGETS`, `DEVRC_GATE_PYTEST_RUNNER`, `DEVRC_GATE_NODE_RUNNER` or `MIN_TESTS` is set in its environment — each changes what the gate runs or accepts while `GATE: RESULT=PASS` still reads as a full verdict, and a replaced runner can print a green one having run nothing. Measured 2026-09-08: `DEVRC_TARGETS= scripts/gate.sh --tier pytest` printed `GATE: RESULT=PASS exit=0` off 12 tests. ⚠ A zero-test invocation (`--check-targets`/`--check-floors`/`--check-suites`) now reports `SCOPE: NONE`, never `FULL`. +- **Run the gate with `scripts/gate.sh`** (`--tier pytest|node|go|all`, `--set hermetic|all`). 🔴 **THERE ARE THREE TIERS NOW, NOT TWO** — `go` (`scripts/run-go-tests.sh`, `checks.gotests`) joined `pytest` and `node` with the `mention-review` TUI. **`--tier both` is an ALIAS for `all`**, deliberately: a caller who typed `both` before Go existed wanted the whole gate, and silently excluding a language from it is the "list that only grows when somebody remembers" defect. ⚠ **Whether CI builds the `gotests` leg is decided in the infra repo** (`devrc-ci-pipeline.yaml` hardcodes its legs) — `gh pr checks ` is what answers that; until you have checked, assume it gates nothing. It sends the full output to a LOG FILE and prints only a bounded summary, so there is no reason to pipe it — and **its exit status is authoritative**. It also cross-checks that status against the runners' own `RESULT:` line and exits **90 = could-not-vouch** when they disagree, when a run printed no verdict, or when `panic: test timed out` appears. 90 is not "the tests failed"; it means read the log. 🔴 It also exits **91 = PARTIAL** when every tier passed but one did not report `SCOPE: FULL` — a narrowed run, or one that printed no scope at all. Neither 90 nor 91 is a gate PASS. It refuses outright (exit 2) if any of `DEVRC_TARGETS`, `DEVRC_GATE_PYTEST_RUNNER`, `DEVRC_GATE_NODE_RUNNER` or `MIN_TESTS` is set in its environment — each changes what the gate runs or accepts while `GATE: RESULT=PASS` still reads as a full verdict, and a replaced runner can print a green one having run nothing. Measured 2026-09-08: `DEVRC_TARGETS= scripts/gate.sh --tier pytest` printed `GATE: RESULT=PASS exit=0` off 12 tests. ⚠ A zero-test invocation (`--check-targets`/`--check-floors`/`--check-suites`) now reports `SCOPE: NONE`, never `FULL`. 🔴 **It RE-ENTERS `nix develop` itself — a pause at the start that is not a hang.** Launched outside a gate environment it re-execs into the repo's dev shell instead of printing the `nix develop …` line for you to re-type. Measured 2026-09-08 across every gate log dir on the diff --git a/flake.nix b/flake.nix index 9715d599c..1f23c13bf 100644 --- a/flake.nix +++ b/flake.nix @@ -108,10 +108,38 @@ nvim-octo = import ./nix/pkgs/tools/nvim-octo { pkgs = final; }; }; + # --------------------------------------------------------------------- + # mention-review — the Go replacement for nvim-octo, PHASE 1 (read-only). + # + # 🔴 IT IS AN OVERLAY ATTRIBUTE FOR THE SAME REASON nvim-octo IS, AND FOR + # A REASON THAT DOES NOT APPLY YET. + # + # Today `mention-open.py` still spawns `nvim-octo`, so `pkgs.mention-review` + # is NOT in the Alacritty hint wrapper's `lib.makeBinPath` — and it must + # not be: `test_mention_open.py::test_the_alacritty_wrapper_PATH_covers_ + # every_executable_the_handler_spawns` pins that list TWO-WAY, so a package + # the handler does not spawn fails the suite as "dead weight in the + # closure". That is the system working. + # + # It is spelled as an overlay attribute anyway so that the day the click + # path flips, the wrapper entry is `pkgs.mention-review` — which is the + # spelling that reader's `pkgs\.[A-Za-z0-9_-]+` scan can SEE. A local + # `let`-bound derivation would make the wrapper look like it pins nothing. + # + # 🔴 IT CAN EVALUATE TO `null`. `nix/pkgs/tools/mention-review/default.nix` + # yields null when it cannot read exactly one `var buildVersion` line out + # of the Go source — a package that cannot state truthfully what it is + # building is not installed, rather than labelled with a guess. The + # consumer in `nix/pkgs/tools/default.nix` filters nulls. + # --------------------------------------------------------------------- + mentionReviewOverlay = final: _prev: { + mention-review = import ./nix/pkgs/tools/mention-review { pkgs = final; }; + }; + pkgs = import nixpkgs { inherit system; config.allowUnfree = true; - overlays = [ nvimOctoOverlay ]; + overlays = [ nvimOctoOverlay mentionReviewOverlay ]; }; # Same allowUnfree treatment for the frozen 1.57 nixpkgs — the browser # bundle is unfree there too, and an --impure fallback would make the @@ -176,6 +204,20 @@ gatePyEnv pkgs.bash pkgs.ripgrep pkgs.git pkgs.util-linux pkgs.jq pkgs.gnugrep pkgs.curl pkgs.nodejs pkgs.nix pkgs.opencode pkgs.logrotate pkgs.rsync pkgs.zsh pkgs.age pkgs.dash + # 🔴 go — THE THIRD TEST TIER'S TOOLCHAIN. `scripts/run-go-tests.sh` + # exits 3 when `go` is not on PATH rather than reporting a pass, so + # without this entry the dev-host go tier is UNRUNNABLE from the very + # shell the FATAL tells you to enter. It is here for the identical + # reason `nodejs` is: one list, so `nix develop` and every check tier + # satisfy the precondition from one place. + # + # ⚠ IT COSTS THE OTHER TWO TIERS A LARGER CLOSURE, and that is stated + # rather than waved away. `gateTools` backs `checks.pytests` and + # `checks.nodetests` too, so adding go invalidates their build cache + # once and grows what a cold CI store must fetch. The alternative — a + # go-only tool list — is a second hand-maintained copy, which is + # precisely the drift this binding was hoisted to prevent. + pkgs.go # 🔴 cairn — THE PINNED CLIENT, AND IT IS NOT A TEST-ONLY CONVENIENCE. # devrc deleted its five forked reader modules and resolves them from # this package at runtime (`scripts/lib/cairn_pin.py`: `which cairn` -> @@ -619,6 +661,94 @@ touch "$out" ''; + # --------------------------------------------------------------------- + # 🔴 THE THIRD TIER. Go, and it is a SEPARATE derivation from the + # package the hosts install, on purpose. + # + # `nix/pkgs/tools/mention-review/default.nix` sets `doCheck = false`. + # Verified in the pinned nixpkgs rather than assumed: + # `pkgs/build-support/go/module.nix` defaults `doCheck` to TRUE and its + # check phase runs `buildGoDir test` over every test directory — so on a + # package in `home.packages` a red Go test FAILS A `home-manager switch`, + # which `ship.sh` reports as a SKIPPED host: the failure mode this repo's + # CLAUDE.md documents as silently stopping all future delivery to that + # machine. The tests belong in a gate leg, not in the deploy path, + # exactly as `pytests` and `nodetests` are separate from it. + # + # 🔴 AND WHETHER CI BUILDS IT IS DECIDED IN ANOTHER REPO — the same + # caveat the `cairn-client-runs` block below spells out at length. + # `devrc-ci-pipeline.yaml` lives in the infra repo and hardcodes its legs + # (`LEG` ∈ {pytests, nodetests} when this was written), with no + # `nix flake check` and no loop. So this output exists and may be built + # by nobody. Check, do not assume: + # + # gh pr checks # is a `gotests` leg listed? + # + # 🔴 UNTIL YOU HAVE CHECKED, ASSUME IT GATES NOTHING. Run it by hand: + # nix build .#checks.x86_64-linux.gotests + # (~27 s on this host, measured; one at a time — a combined invocation + # with the other checks contends on the store and produces FALSE + # failures.) + # + # 🔴 NO NETWORK IN THE SANDBOX, AND `goModules` IS A *VENDOR TREE*, NOT + # A MODULE CACHE. Measured, not assumed: its output is + # `charm.land/ github.com/ golang.org/ gopkg.in/ modules.txt` — the + # `vendor/` layout. Pointing `GOMODCACHE` at it fails with + # `go: could not create module cache: mkdir …/pkg: permission denied`, + # which is what the first version of this block did. It is COPIED into + # the module as `vendor/` and used with `-mod=vendor`, so this leg tests + # the SAME dependency set the deployed binary is built from rather than + # a second resolution of it — and needs no network to do it. + gotests = + pkgs.runCommandLocal "devrc-gotests" + { + # 🔴 `stdenv.cc` IS LOAD-BEARING, NOT PADDING. Without a C + # compiler `go test` cannot build `runtime/cgo`, which `net` pulls + # in for its cgo resolver — so every package that transitively + # imports `net/http` fails to build. MEASURED here: four of five + # packages reported `[build failed]` with + # `cgo: C compiler "gcc" not found`, and `internal/argv` passed + # because it imports nothing but `strings`. + # + # ⚠ THE ALTERNATIVE — `CGO_ENABLED=0` — WAS REJECTED. It would make + # this tier compile the code DIFFERENTLY from the way + # `buildGoModule` compiles the binary the hosts install, and a tier + # that builds differently is blind to different things. The whole + # value of a second tier is that its blind spots differ from the + # dev host's by ACCIDENT of environment, not by a flag we chose. + nativeBuildInputs = [ + pkgs.go pkgs.bash pkgs.git pkgs.gnugrep pkgs.coreutils pkgs.stdenv.cc + ]; + } + '' + cp -r ${./.} src + chmod -R u+w src + export HOME="$TMPDIR/home" + mkdir -p "$HOME" + export GOCACHE="$TMPDIR/go-build" + export GOPATH="$TMPDIR/go" + # The vendor tree, copied WRITABLE — the store copy is read-only and + # the go tool wants to stat/lock inside it. + cp -r ${pkgs.mention-review.goModules} src/nix/pkgs/tools/mention-review/src/vendor + chmod -R u+w src/nix/pkgs/tools/mention-review/src/vendor + export GOFLAGS="-mod=vendor" + export GOPROXY=off + cd src + # The runner pins its package list two-way, asserts per-package + # TEST-COUNT floors, counts `go test -json` records rather than + # reading an exit code, and caps SKIPS at zero. Read its header + # before changing this line. + # + # rc captured explicitly — same reason as checks.pytests above. + rc=0 + bash scripts/run-go-tests.sh . || rc=$? + if [ "$rc" -ne 0 ]; then + echo "checks.gotests: run-go-tests.sh exited $rc — failing the derivation." >&2 + exit "$rc" + fi + touch "$out" + ''; + # 🔴 WHETHER THIS IS A GATE OR ONLY AN OUTPUT IS DECIDED IN ANOTHER # REPO, SO VERIFY IT — DO NOT TRUST THIS COMMENT'S TENSE. # `devrc-ci-pipeline.yaml` lives in the infra repo. It hardcoded exactly diff --git a/nix/home.nix b/nix/home.nix index 53a405b98..d33e8ba1b 100644 --- a/nix/home.nix +++ b/nix/home.nix @@ -3674,7 +3674,8 @@ in # right per run and wrong forever".) # rc 10 = RED REPRODUCED and rc 12 = BLIND both fail the unit on purpose. SuccessExitStatus = 11; - # Two attempts x two tiers. The pytests derivation alone ran 18 min on a + # Two attempts x three tiers (pytests, nodetests, gotests). The pytests + # derivation alone ran 18 min on a # loaded box, so a 420s budget like drift-check's would kill this mid-build # and report a timeout as a failure — an alarm about the alarm. TimeoutStartSec = 5400; diff --git a/nix/pkgs/tools/default.nix b/nix/pkgs/tools/default.nix index 46870d3e7..cf6442dc3 100644 --- a/nix/pkgs/tools/default.nix +++ b/nix/pkgs/tools/default.nix @@ -65,3 +65,19 @@ with pkgs; [ # and not fetchFromGitHub, and it yields [] on a host without that checkout # rather than failing the switch. ++ (import ./clawgatectl.nix { inherit pkgs workspace; }) +# mention-review — PHASE 1 of the nvim-octo replacement, read-only. +# +# 🔴 ON PATH SO THE OPERATOR CAN RUN IT BY HAND. The click path still spawns +# `nvim-octo`; the proposal is explicit that the retirement ships "only after +# the operator has used the new TUI for a real review", and nothing headless can +# close that condition. `mention-review ` is how that +# happens. Flipping the click over is one line in `mention-open.py`. +# +# 🔴 THE NULL FILTER IS LOAD-BEARING, NOT DEFENSIVE. The derivation evaluates to +# `null` when it cannot read exactly one `var buildVersion` line out of the Go +# source — deliberately, so a binary is never labelled with a guessed version. +# Without this filter that null would land in `home.packages` and fail the +# SWITCH, which ship.sh reports as a SKIPPED host: the failure mode this repo's +# CLAUDE.md documents as silently stopping all future delivery to that machine. +# The whole point of the null is to be quieter than that. +++ (pkgs.lib.optional (pkgs.mention-review != null) pkgs.mention-review) diff --git a/nix/pkgs/tools/mention-review/default.nix b/nix/pkgs/tools/mention-review/default.nix new file mode 100644 index 000000000..5dd9a9f36 --- /dev/null +++ b/nix/pkgs/tools/mention-review/default.nix @@ -0,0 +1,143 @@ +# mention-review — a single-purpose Go TUI for reading one GitHub pull request. +# +# WHAT IT IS FOR: the same job `nvim-octo` does today. A clicked GitHub `repo#N` +# mention in the terminal opens a review surface instead of a browser tab. +# +# 🔴 PHASE 1. READ-ONLY. `nvim-octo` IS STILL THE PACKAGE THE CLICK PATH USES. +# `scripts/mention-open.py`'s `REVIEW_EXE` still says `nvim-octo`, deliberately: +# the retirement is a separate, later, revertable step, and the proposal is +# explicit that it ships "only after the operator has used the new TUI for a +# real review". Until then this binary is on PATH and invoked by hand: +# mention-review +# Flipping the click path over is ONE line in `mention-open.py`, and flipping it +# back is the same line. +# +# --------------------------------------------------------------------------- +# 🔴 SOURCE REFERENCE STRATEGY: A LOCAL PATH INSIDE THIS REPO. +# +# Unlike `clawgatectl.nix` next door — which points at a working tree of a +# DIFFERENT, private repo and therefore has to guard on `pathExists` — the Go +# module here lives in this repo, at ./src. There is no cross-repo drift window +# and no credential question. What IS carried over from that file is the +# version mechanism, because the failure it prevents is not about repositories. +# +# 🔴 THE VERSION IS READ OUT OF THE SOURCE BEING COMPILED. IT IS NOT WRITTEN +# HERE. `clawgatectl.nix` exists in its current form because a hand-maintained +# `version = "x.y.z"` literal was a claim about code that nothing kept in step, +# and on 2026-08-14 it stamped `0.7.95` onto a binary built from `0.7.87` +# source — producing a CLI that printed help and exited 0 for a subcommand it +# did not have. Both halves live in this repo, so the window is smaller; the +# mechanism is free and the failure mode is identical, so it is kept. +# +# 🔴 AN UNPARSEABLE SOURCE MUST NOT FALL BACK TO A LITERAL — that is the same +# lie in a new shape. It sets `available = false` instead, so the binary is +# simply not installed and the failure is LOUD at use time +# (`mention-review: command not found`) rather than silent at build time. +# Failing the SWITCH is the worse outcome and is deliberately not what happens: +# `ship.sh` reports a failed switch as a SKIPPED host, which this repo's +# CLAUDE.md documents as the failure mode that silently stops all future +# delivery to that machine. +# +# 🔴 `doCheck = false`, AND IT IS NOT A CONVENIENCE. Verified in the pinned +# nixpkgs rather than assumed: `pkgs/build-support/go/module.nix` defaults +# `doCheck` to TRUE and its check phase runs `buildGoDir test` over every test +# directory. On a package in `home.packages` that makes a red Go test fail a +# `home-manager switch` — i.e. the skipped-host failure above, triggered by a +# test. The tests are run by a SEPARATE gate leg (`scripts/run-go-tests.sh` and +# `checks.gotests`), exactly as `pytests`/`nodetests` are separate from the +# deploy path. +{ pkgs, lib ? pkgs.lib }: + +let + srcDir = ./src; + + # The single source of truth for the version, and the file whose + # `var buildVersion = "…"` line is BOTH the Go default and what this + # derivation stamps back in. + versionFile = ./src/cmd/mention-review/version.go; + + # 🔴 EXACTLY ONE MATCHING LINE, OR NOTHING. Zero matches means the + # declaration was renamed or reformatted; two or more means the pattern has + # become ambiguous and picking either one would be a guess presented as a + # fact. Both land on `null`, which switches the package off — see the header + # for why that is preferable to both a literal fallback and a failed switch. + versionPattern = "var buildVersion = \"([^\"]+)\".*"; + + versionLines = + if builtins.pathExists versionFile + then + builtins.filter + (l: builtins.isString l && builtins.match versionPattern l != null) + # builtins.split yields the separators as LISTS between the string + # pieces, hence the isString filter above. + (builtins.split "\n" (builtins.readFile versionFile)) + else [ ]; + + parsedVersion = + if builtins.length versionLines == 1 + then builtins.head (builtins.match versionPattern (builtins.head versionLines)) + else null; + + # `main.go` answers "is this tree complete enough to build at all"; + # `parsedVersion` answers "can this derivation state truthfully what it is + # building". A package that cannot answer the second is not installed — it is + # never labelled with a guess. + available = + builtins.pathExists ./src/cmd/mention-review/main.go && parsedVersion != null; + + mention-review = pkgs.buildGoModule { + pname = "mention-review"; + version = parsedVersion; + + src = lib.cleanSource srcDir; + + # 🔴 MEASURED by building with a deliberately wrong hash and taking the + # "got:" value from the failure. NEVER hand-edited: change go.mod/go.sum and + # this must be re-derived the same way. + vendorHash = "sha256-gtKRWZmVWKjm5Xro0FKRQpVwkQZyYLmdfl3DumxnPgc="; + + subPackages = [ "cmd/mention-review" ]; + + # 🔴 SEE THE HEADER. A red Go test must not be able to fail a + # `home-manager switch`. The tests run in their own gate leg. + doCheck = false; + + # 🔴 THE STAMP IS AN IDENTITY — `parsedVersion` was read out of the very + # `var buildVersion` line this overwrites. That is the property that makes + # the store path and the compiled-in string provably the same value. It + # must never be given anything but `parsedVersion`: the moment it can + # differ, the binary can lie about itself again. + ldflags = [ "-s" "-w" "-X main.buildVersion=${parsedVersion}" ]; + + # 🔴 `xdg-open` IS PINNED BY STORE PATH, NOT INHERITED — the same reason + # `nvim-octo` pins `gh`. The whole call chain starts in an Alacritty hint + # spawned with the DISPLAY MANAGER's environment, and `~/.nix-profile` is + # blanked for ~30 s during every `home-manager switch`, so a click landing + # in that window would find nothing by bare name. + # + # ⚠ `pkgs.gh` IS DELIBERATELY ABSENT, AND THAT IS A CLAIM WORTH CHECKING + # AGAIN IF AUTH CHANGES. `go-gh`'s token precedence is: GH_TOKEN / + # GITHUB_TOKEN, then `~/.config/gh/hosts.yml`, then — and only then — a + # SUBPROCESS `gh auth token --secure-storage`. MEASURED on this host: + # `hosts.yml` carries plaintext `oauth_token` entries and the session bus + # exposes no `org.freedesktop.secrets`, so rung 2 fires and rung 3 never + # runs. If it ever does, `TokenForHost` returns ("", "default") SILENTLY + # with no error — which this binary renders as the `NO TOKEN` card rather + # than as an empty PR view, so the failure is legible rather than + # mysterious. Adding `pkgs.gh` here would pull a large closure to cover a + # rung that is not reached; the honest card is the cheaper mitigation. + nativeBuildInputs = [ pkgs.makeWrapper ]; + postInstall = '' + wrapProgram $out/bin/mention-review \ + --prefix PATH : ${lib.makeBinPath [ pkgs.xdg-utils ]} + ''; + + meta = with lib; { + description = "Single-purpose TUI for reading one GitHub pull request"; + mainProgram = "mention-review"; + license = licenses.mit; + platforms = platforms.linux; + }; + }; +in +if available then mention-review else null diff --git a/nix/pkgs/tools/mention-review/src/.gitignore b/nix/pkgs/tools/mention-review/src/.gitignore new file mode 100644 index 000000000..708c6ab97 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/.gitignore @@ -0,0 +1,14 @@ +# 🔴 `vendor/` IS BUILD OUTPUT HERE, NEVER SOURCE. +# +# `checks.gotests` materialises a vendor tree inside the sandbox copy of this +# module — `cp -r ${pkgs.mention-review.goModules} …/vendor` — because the nix +# sandbox has no network and `goModules` IS a vendor layout. Reproducing that +# locally to debug the sandbox tier is a normal thing to do, and it leaves a +# ~40 MB tree sitting right here. +# +# Committing it would be worse than large: `go` prefers `vendor/` over the +# module cache whenever it is present and consistent, so a stale committed copy +# would silently become the dependency set the dev-host tier tests — while +# `buildGoModule` keeps building the binary from `go.sum` + `vendorHash`. The +# two tiers would then compile different code with nothing saying so. +vendor/ diff --git a/nix/pkgs/tools/mention-review/src/cmd/mention-review/main.go b/nix/pkgs/tools/mention-review/src/cmd/mention-review/main.go new file mode 100644 index 000000000..76995f618 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/cmd/mention-review/main.go @@ -0,0 +1,65 @@ +// Command mention-review is a single-purpose TUI for reading one GitHub pull +// request, spawned by the Alacritty mention-hint path. +// +// 🔴 PHASE 1 IS READ-ONLY. There are no write actions — no comment, no +// approve, no merge. The confirmation ledger in `internal/ui/intents.go` exists +// and is enforced two-way so Phase 2 cannot add one silently, but today it is +// legitimately empty and its test says so with a positive control rather than +// asserting a bare zero. +// +// Usage: mention-review +// Exit: 64 wrong argument count · 65 malformed owner/repo · 66 bad number +package main + +import ( + "fmt" + "net/http" + "os" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/cli/go-gh/v2/pkg/auth" + + "github.com/innovation-upstream/devrc/mention-review/internal/argv" + "github.com/innovation-upstream/devrc/mention-review/internal/ghapi" + "github.com/innovation-upstream/devrc/mention-review/internal/ui" +) + +func main() { + // 🔴 `--version` IS ANSWERED BEFORE ARGUMENT VALIDATION, and it prints the + // value `default.nix` read out of version.go. The Nix expression stamps + // this same string back via `-X`, so the store path and the compiled-in + // version are provably one value — which is the whole point of reading the + // version out of the source rather than writing it in the derivation. + if len(os.Args) == 2 && (os.Args[1] == "--version" || os.Args[1] == "-v") { + fmt.Println(buildVersion) + return + } + + args, aerr := argv.Parse(os.Args[1:]) + if aerr != nil { + // 🔴 BEFORE DRAWING ANYTHING. This is the ONE failure that exits rather + // than rendering a card, because there is no window to keep open yet — + // exactly as `nvim-octo` behaves today, and the contract + // `scripts/tests/test_nvim_octo.py` pins. + fmt.Fprintln(os.Stderr, aerr.Msg) + os.Exit(aerr.Code) + } + + // 🔴 AN EMPTY TOKEN IS NOT AN ERROR HERE — go-gh's fourth precedence rung + // returns ("", "default") with NO error, so `err` is not the signal. The + // emptiness is carried into the client, which turns the first request into + // a NO TOKEN card. Exiting here instead would flash a window and vanish. + token, _ := auth.TokenForHost("github.com") + + client := ghapi.NewClient(token, &http.Client{Timeout: 30 * time.Second}) + + app := ui.New(args.Owner, args.Name, args.Num) + app.SetRunner(ui.LiveRunner{C: client}) + + p := tea.NewProgram(app) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, "mention-review:", err) + os.Exit(1) + } +} diff --git a/nix/pkgs/tools/mention-review/src/cmd/mention-review/main_test.go b/nix/pkgs/tools/mention-review/src/cmd/mention-review/main_test.go new file mode 100644 index 000000000..4969a609b --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/cmd/mention-review/main_test.go @@ -0,0 +1,167 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// 🔴 LAYER 3(e) — THE ARGV CONTRACT, DRIVEN AGAINST THE *BINARY*. +// +// `internal/argv` already tests the parser exhaustively. This tests something +// the parser tests structurally cannot: that `main` WIRES it to `os.Exit` with +// the right code, before drawing anything and before touching the network. +// +// Ported from `scripts/tests/test_nvim_octo.py`, which pins the same contract +// for the implementation this one replaces, so the contract survives the swap +// rather than being re-derived. +// +// 🔴 EXIT CODES ARE ASSERTED BY VALUE, NEVER AS "NON-ZERO". Non-zero is also +// what a missing binary, a panic and a failed dynamic link produce — and +// `_run_wrapper`'s note in the Python file records 18 tests that passed against +// a tree with no wrapper at all. + +var binary string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "mention-review-argv-") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + + binary = filepath.Join(dir, "mention-review") + build := exec.Command("go", "build", "-o", binary, ".") + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + panic("could not build the binary under test: " + err.Error()) + } + os.Exit(m.Run()) +} + +// run executes the binary with a DELIBERATELY EMPTY credential environment. +// +// 🔴 THE POINT IS THAT NONE OF THESE CASES REACHES THE NETWORK ANYWAY — every +// one of them must exit on argv before a token is even resolved. Clearing the +// environment makes that a property the test enforces rather than one it hopes +// for: if a case ever DID reach the fetch, it would hit the NO TOKEN path and +// hang on a TUI instead of exiting, which the timeout would then catch. +func run(t *testing.T, args ...string) (rc int, stdout, stderr string) { + t.Helper() + cmd := exec.Command(binary, args...) + cmd.Env = []string{"HOME=/nonexistent", "PATH=/nonexistent", "GH_CONFIG_DIR=/nonexistent"} + var out, errb strings.Builder + cmd.Stdout = &out + cmd.Stderr = &errb + err := cmd.Run() + if ee, ok := err.(*exec.ExitError); ok { + return ee.ExitCode(), out.String(), errb.String() + } + if err != nil { + t.Fatalf("running the binary: %v", err) + } + return 0, out.String(), errb.String() +} + +func TestTheWrongNumberOfArgumentsExits64(t *testing.T) { + for _, args := range [][]string{ + {}, + {"only-one"}, + {"gardenersguild/trowelcast", "1559", "extra"}, + } { + rc, _, stderr := run(t, args...) + if rc != 64 { + t.Errorf("%v: exit %d, want 64 (%q)", args, rc, stderr) + } + if !strings.Contains(stderr, "usage:") { + t.Errorf("%v: stderr = %q, want a usage line", args, stderr) + } + } +} + +func TestAMalformedRepositoryExits65(t *testing.T) { + for _, repo := range []string{ + "notarepo", + "too/many/slashes", + "/leadingslash", + "trailing/", + "../../etc/passwd", + "owner/repo;rm -rf /", + "owner/repo with space", + "owner/$(whoami)", + "", + } { + rc, _, stderr := run(t, repo, "1559") + if rc != 65 { + t.Errorf("%q: exit %d, want 65 (%q)", repo, rc, stderr) + } + if !strings.Contains(stderr, "not an owner/repo") { + t.Errorf("%q: stderr = %q, want THIS guard's own message", repo, stderr) + } + } +} + +func TestABadNumberExits66(t *testing.T) { + for _, num := range []string{"", "abc", "12a", "-1", "1.5", "1 2", "$(id)", "42;ls"} { + rc, _, stderr := run(t, "gardenersguild/trowelcast", num) + if rc != 66 { + t.Errorf("%q: exit %d, want 66 (%q)", num, rc, stderr) + } + if !strings.Contains(stderr, "not a reference number") { + t.Errorf("%q: stderr = %q, want THIS guard's own message", num, stderr) + } + } +} + +// 🔴 THE THREE CODES ARE DISTINCT, ASSERTED DIRECTLY. Without this, a build +// that collapsed them all onto 64 would pass every test above that happened to +// exercise the arity guard first. +func TestTheThreeExitCodesAreDistinct(t *testing.T) { + usage, _, _ := run(t) + repo, _, _ := run(t, "notarepo", "1559") + num, _, _ := run(t, "gardenersguild/trowelcast", "abc") + if usage == repo || repo == num || usage == num { + t.Fatalf("exit codes collapsed: usage=%d repo=%d num=%d", usage, repo, num) + } +} + +// 🔴 `--version` PRINTS THE VALUE `default.nix` READS OUT OF version.go, AND +// THE TWO ARE PINNED TOGETHER. +// +// The Nix expression matches `var buildVersion = "([^"]+)".*` in version.go and +// stamps the captured string back via `-X`. This asserts the running binary +// reports the same string THAT PATTERN would capture — so the store path's +// version and the compiled-in one are provably one value, which is the entire +// point of reading the version out of the source instead of writing it in the +// derivation. +func TestVersionMatchesWhatTheNixPatternWouldCapture(t *testing.T) { + src, err := os.ReadFile("version.go") + if err != nil { + t.Fatal(err) + } + // The SAME regular expression `default.nix` uses, transcribed from Nix's + // `builtins.match` (which is anchored) to Go's syntax. + re := regexp.MustCompile(`(?m)^var buildVersion = "([^"]+)".*$`) + all := re.FindAllStringSubmatch(string(src), -1) + // 🔴 EXACTLY ONE MATCHING LINE, OR NOTHING — the Nix side switches the + // package OFF on zero or two matches rather than guessing. This test is + // what stops that state reaching a build. + if len(all) != 1 { + t.Fatalf("version.go has %d lines matching the Nix pattern, want exactly 1", len(all)) + } + want := all[0][1] + + rc, stdout, stderr := run(t, "--version") + if rc != 0 { + t.Fatalf("--version exited %d (%q)", rc, stderr) + } + if got := strings.TrimSpace(stdout); got != want { + t.Errorf("the binary reports %q, the Nix pattern would capture %q", got, want) + } + if want == "" { + t.Error("the captured version is empty — the comparison above is vacuous") + } +} diff --git a/nix/pkgs/tools/mention-review/src/cmd/mention-review/version.go b/nix/pkgs/tools/mention-review/src/cmd/mention-review/version.go new file mode 100644 index 000000000..8aab983f9 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/cmd/mention-review/version.go @@ -0,0 +1,30 @@ +package main + +// 🔴 THIS LINE IS THE SINGLE SOURCE OF TRUTH FOR THE VERSION, AND +// `nix/pkgs/tools/mention-review/default.nix` READS IT OUT OF THIS FILE. +// +// It is never spelled in the Nix expression. `clawgatectl.nix` exists in its +// current form because a hand-maintained `version = "x.y.z"` literal there was +// a claim about code somewhere else, and on 2026-08-14 it stamped `0.7.95` onto +// a binary built from `0.7.87` source — producing a CLI that printed help and +// exited 0 for a subcommand it did not have. Both halves live in this repo +// here, so the drift window is smaller, but the mechanism is free and the +// failure mode is identical. +// +// 🔴 THE NIX SIDE MATCHES THIS EXACT SHAPE AND NOTHING ELSE: +// +// var buildVersion = "([^"]+)".* +// +// EXACTLY ONE matching line in this file, or the package is not built at all +// (`available = false`). Zero matches means the declaration was renamed or +// reformatted; two or more means the pattern became ambiguous and picking +// either would be a guess presented as a fact. Do not add a second `var +// buildVersion` anywhere in this file, do not reformat this declaration across +// two lines, and 🔴 do not give the Nix side a fallback literal — a package +// that cannot state truthfully what it is building must not be installed. +// +// Failing the *switch* is the worse outcome and is deliberately not what +// happens: ship.sh reports a failed switch as a SKIPPED host, which this +// repo's CLAUDE.md documents as silently stopping all future delivery to that +// machine. `mention-review: command not found` is the loud failure instead. +var buildVersion = "0.1.0" diff --git a/nix/pkgs/tools/mention-review/src/go.mod b/nix/pkgs/tools/mention-review/src/go.mod new file mode 100644 index 000000000..66a3a7344 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/go.mod @@ -0,0 +1,42 @@ +// 🔴 THE MODULE PATH IS NOT A FETCHABLE URL, AND THAT IS DELIBERATE. +// +// The source lives at `nix/pkgs/tools/mention-review/src/` inside this repo, so +// the honest import path would be +// `github.com/innovation-upstream/devrc/nix/pkgs/tools/mention-review/src`, +// which every import statement would then carry. Nothing ever `go get`s this — +// it is built by `default.nix` from a local path, exactly as `clawgatectl` is — +// so the shorter path is chosen for readability and recorded here rather than +// left to be rediscovered. +module github.com/innovation-upstream/devrc/mention-review + +// Floor matches nixpkgs' Go and Bubble Tea v2's own `go 1.25.0` requirement. +go 1.25.0 + +require ( + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.9 + charm.land/lipgloss/v2 v2.0.6 + github.com/bluekeyes/go-gitdiff v0.9.0 + github.com/cli/go-gh/v2 v2.16.0 +) + +require ( + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/cli/safeexec v1.0.1 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/nix/pkgs/tools/mention-review/src/go.sum b/nix/pkgs/tools/mention-review/src/go.sum new file mode 100644 index 000000000..4ad81ed0c --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/go.sum @@ -0,0 +1,65 @@ +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= +charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/bluekeyes/go-gitdiff v0.9.0 h1:w+O6lkRBOqfGcwF0Lf6FFHQrhmxM0hCJW5+rbilGuSs= +github.com/bluekeyes/go-gitdiff v0.9.0/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/cli/go-gh/v2 v2.16.0 h1:xaePUubgeuj4wKz87NIo+zFQtuB6566K8cAGTh0Ctjc= +github.com/cli/go-gh/v2 v2.16.0/go.mod h1:OaJTFtHJapQq670h/3L0vqm4NwZGoJmSAVctWiY+3pQ= +github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00= +github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/nix/pkgs/tools/mention-review/src/internal/argv/argv.go b/nix/pkgs/tools/mention-review/src/internal/argv/argv.go new file mode 100644 index 000000000..648202d5f --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/argv/argv.go @@ -0,0 +1,164 @@ +// Package argv holds the command-line contract, and NOTHING else. +// +// 🔴 THE CONTRACT IS INHERITED, NOT INVENTED. `scripts/mention-open.py` spawns +// `alacritty … -e ` — two plain argv entries, +// no quoting anywhere. `nvim-octo.sh` validates them today and exits 64/65/66; +// `scripts/tests/test_nvim_octo.py` pins that table. This package reproduces it +// so the contract survives an implementation swap rather than being re-derived, +// and `argv_test.go` carries the same cases so the two implementations can be +// compared case for case. +// +// 🔴 THREE DISTINCT EXIT CODES, ON PURPOSE. A mutation test that breaks the +// repository check and watches "a test fail" is green for the wrong reason if +// the NUMBER guard is the one that fired. Distinct codes plus distinct messages +// make each guard's kill attributable to itself. +package argv + +import "strings" + +// Exit codes. Asserted by VALUE everywhere, never as "non-zero" — non-zero is +// also what a missing binary, a panic and a failed dynamic link produce. +const ( + ExitUsage = 64 // wrong number of arguments + ExitBadRepo = 65 // malformed owner/repo + ExitBadNum = 66 // non-numeric or empty number +) + +// Args is a validated invocation. +type Args struct { + Owner string + Name string + Num int +} + +// Repo renders the "owner/repo" spelling the GitHub API and the UI both want. +func (a Args) Repo() string { return a.Owner + "/" + a.Name } + +// Error carries the exit code AND the message, because the pair is the +// contract: an operator who sees a window close learns nothing from the code, +// and a test that asserts only the code passes against a wrapper that launched +// first and complained after. +type Error struct { + Code int + Msg string +} + +func (e *Error) Error() string { return e.Msg } + +// Parse validates the two positional arguments. +// +// `args` is argv WITHOUT the program name — i.e. os.Args[1:]. +func Parse(args []string) (Args, *Error) { + if len(args) != 2 { + return Args{}, &Error{ + Code: ExitUsage, + Msg: "usage: mention-review ", + } + } + repo, num := args[0], args[1] + + owner, name, ok := splitRepo(repo) + if !ok { + return Args{}, &Error{ + Code: ExitBadRepo, + Msg: "mention-review: not an owner/repo: " + repo, + } + } + n, ok := parseNum(num) + if !ok { + return Args{}, &Error{ + Code: ExitBadNum, + Msg: "mention-review: not a reference number: " + num, + } + } + return Args{Owner: owner, Name: name, Num: n}, nil +} + +// splitRepo accepts exactly one slash, no traversal, and only the characters +// GitHub allows in an owner or a repository name. +// +// ⚠ THE REJECTIONS COME FIRST AND THEY WIN. `strings.Cut` alone would accept +// `../../etc/passwd`, which HAS a slash — the shell version this is ported from +// records the same ordering trap in its `case` glob, where the negative arm is +// deliberately first. +func splitRepo(s string) (owner, name string, ok bool) { + if s == "" { + return "", "", false + } + // `..` anywhere — traversal, and it also covers `.` -only segments that + // would resolve outside the intended repository when interpolated. + if strings.Contains(s, "..") { + return "", "", false + } + for _, r := range s { + if !allowedRepoRune(r) { + return "", "", false + } + } + // Exactly one slash, and neither side empty. This one check subsumes "no + // slash at all", "a second slash", "a leading slash" and "a trailing + // slash" — the four cases the shell glob spells separately. + owner, name, found := strings.Cut(s, "/") + if !found || owner == "" || name == "" || strings.Contains(name, "/") { + return "", "", false + } + return owner, name, true +} + +// allowedRepoRune is the character class GitHub actually permits. It is an +// ALLOWLIST rather than a denylist of metacharacters: a denylist has to +// enumerate every shell, every interpolation context and every future one. +func allowedRepoRune(r rune) bool { + switch { + case r >= 'a' && r <= 'z': + return true + case r >= 'A' && r <= 'Z': + return true + case r >= '0' && r <= '9': + return true + case r == '.' || r == '_' || r == '-' || r == '/': + return true + } + return false +} + +// maxNum bounds the accumulator. See the DIVERGENCE note on parseNum. +const maxNum = 1 << 40 + +// parseNum requires digits only, at least one. +// +// ⚠ NOT strconv.Atoi. Atoi accepts a leading `+` or `-`, and `-1` is a value +// the shell contract rejects with code 66. The digit walk IS the contract; +// the accumulator is incidental. +// +// 🔴 `"0"` IS ACCEPTED, DELIBERATELY, BECAUSE THE SHELL CONTRACT ACCEPTS IT. +// `case "$num" in "" | *[!0-9]*)` rejects only the empty string and non-digits, +// so `0` reaches octo today. Rejecting it here would make this binary and +// `nvim-octo` disagree about a reachable input while both claim to implement +// one contract — and `0` is not silently wrong, it produces the `NOT FOUND` +// card §6.1 specifies. Matching the existing behaviour is worth more than +// being marginally stricter than it. +// +// ⚠ ONE KNOWN DIVERGENCE, AND IT IS STATED RATHER THAN HIDDEN: a digit string +// long enough to overflow `int` is rejected with 66 where the shell would pass +// it through. Go would wrap silently; the shell hands the string to another +// program. No mention click can produce such an input — `mention-open.py` +// builds the number out of a `#N` match in terminal text — so this arm is +// unreachable from the click path and exists only so the accumulator cannot +// produce a negative or wrapped number if something else ever calls it. +func parseNum(s string) (int, bool) { + if s == "" { + return 0, false + } + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + return 0, false + } + n = n*10 + int(r-'0') + if n > maxNum { + return 0, false + } + } + return n, true +} diff --git a/nix/pkgs/tools/mention-review/src/internal/argv/argv_test.go b/nix/pkgs/tools/mention-review/src/internal/argv/argv_test.go new file mode 100644 index 000000000..d2a4d696f --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/argv/argv_test.go @@ -0,0 +1,222 @@ +package argv + +import "testing" + +// 🔴 THE EXPECTED VALUES ARE WRITTEN BY HAND FROM THE CONTRACT, NEVER DERIVED +// FROM THE IMPLEMENTATION. The contract is `nvim-octo.sh` plus the table in +// `scripts/tests/test_nvim_octo.py`, and these cases are ported from it case +// for case so the contract survives the implementation swap. + +// 🔴 THE THREE CODES ARE PINNED AS LITERALS, AND THIS TEST EXISTS BECAUSE A +// MUTATION SURVIVED WITHOUT IT. +// +// MEASURED: changing `ExitBadRepo = 65` to `= 64` — collapsing the malformed-repo +// code onto the usage code, a real and silent contract break — was NOT caught. +// Every rejection test below compared `err.Code` against the CONSTANT +// `ExitBadRepo`, so the mutant moved the expectation along with the behaviour +// and the suite stayed green. That is the "fixture equals the constant the +// assertion names" trap in its purest form: a test that can only ever compare a +// value with itself cannot see a mutant that changes it. +// +// The control is mechanical — assert values the constants CANNOT supply. +func TestTheExitCodesAreTheSYSEXITSVALUESTheShellContractUses(t *testing.T) { + // Literals, deliberately. `nvim-octo.sh` exits 64/65/66 and + // `test_nvim_octo.py` pins those numbers; these are those numbers, typed + // again rather than referenced. + for _, c := range []struct { + name string + got int + want int + }{ + {"ExitUsage", ExitUsage, 64}, + {"ExitBadRepo", ExitBadRepo, 65}, + {"ExitBadNum", ExitBadNum, 66}, + } { + if c.got != c.want { + t.Errorf("%s = %d, want %d — the shell contract this replaces exits "+ + "with that value, and `mention-open.py`'s caller reads it", + c.name, c.got, c.want) + } + } + // And they are mutually distinct, so a kill is attributable to one guard. + if ExitUsage == ExitBadRepo || ExitBadRepo == ExitBadNum || ExitUsage == ExitBadNum { + t.Fatalf("exit codes collapsed: usage=%d repo=%d num=%d", + ExitUsage, ExitBadRepo, ExitBadNum) + } +} +// +// ⚠ THE FIXTURES ARE PAIRWISE DISTINCT AND DISTINCT FROM EVERY CONSTANT THE +// ASSERTIONS NAME. The numbers are 1559 / 42 / 7, never 0 or 1, and the owners +// and names differ, so a mutant that hardcodes a literal or returns the wrong +// half of the split cannot produce the expected value by accident. + +func TestAGoodInvocationSplitsOwnerAndName(t *testing.T) { + got, err := Parse([]string{"gardenersguild/trowelcast", "1559"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Asserted FIELD BY FIELD, not via Repo(): `Repo()` re-joins with a slash, + // so a mutant that swapped Owner and Name would round-trip through it + // undetected. + if got.Owner != "gardenersguild" { + t.Errorf("Owner = %q, want %q", got.Owner, "gardenersguild") + } + if got.Name != "trowelcast" { + t.Errorf("Name = %q, want %q", got.Name, "trowelcast") + } + if got.Num != 1559 { + t.Errorf("Num = %d, want %d", got.Num, 1559) + } + if got.Repo() != "gardenersguild/trowelcast" { + t.Errorf("Repo() = %q", got.Repo()) + } +} + +// badRepos is ported verbatim from test_nvim_octo.py's parametrize list, plus +// the cases §5.3(e) names explicitly. +var badRepos = []string{ + "notarepo", // no slash at all + "too/many/slashes", // a second slash + "/leadingslash", + "trailing/", + "../../etc/passwd", // traversal, and it HAS a slash + "owner/repo;rm -rf /", // shell metacharacters + "owner/repo with space", + "owner/$(whoami)", + "", + "owner/re..po", // traversal spelled mid-segment + "owner/repo\n", // a newline, which a `case` glob would also reject +} + +func TestABadRepositoryIsRejectedWithItsOwnCodeAndMessage(t *testing.T) { + // 🔴 THREE HALVES, AND THE FIRST IS WHAT MAKES THE OTHER TWO MEAN ANYTHING: + // THIS guard's own exit code, THIS guard's own message, and no Args. A + // code alone would be satisfied by the arity guard firing instead. + for _, repo := range badRepos { + t.Run(repo, func(t *testing.T) { + got, err := Parse([]string{repo, "1559"}) + if err == nil { + t.Fatalf("accepted %q -> %+v", repo, got) + } + if err.Code != ExitBadRepo { + t.Errorf("Code = %d, want %d (%q)", err.Code, ExitBadRepo, err.Msg) + } + if !contains(err.Msg, "not an owner/repo") { + t.Errorf("Msg = %q, want it to name THIS guard", err.Msg) + } + if got != (Args{}) { + t.Errorf("returned Args %+v on a rejection", got) + } + }) + } +} + +// badNums is ported verbatim, plus the empty case §5.3(e) names. +var badNums = []string{"", "abc", "12a", "-1", "1.5", "1 2", "$(id)", "42;ls", "+7", " 7"} + +func TestABadNumberIsRejectedWithADIFFERENTCodeFromTheRepoGuard(t *testing.T) { + // 🔴 A DIFFERENT EXIT CODE, ON PURPOSE. A mutation test that broke the + // repository check and watched "a test fail" would be green for the wrong + // reason if the NUMBER guard was the one that fired. Distinct codes plus + // distinct messages make each guard's kill attributable to itself. + for _, num := range badNums { + t.Run(num, func(t *testing.T) { + _, err := Parse([]string{"rivalorg/spadeworks", num}) + if err == nil { + t.Fatalf("accepted %q", num) + } + if err.Code != ExitBadNum { + t.Errorf("Code = %d, want %d (%q)", err.Code, ExitBadNum, err.Msg) + } + if !contains(err.Msg, "not a reference number") { + t.Errorf("Msg = %q, want it to name THIS guard", err.Msg) + } + }) + } + if ExitBadRepo == ExitBadNum { + t.Fatal("the two guards share an exit code — neither kill is attributable") + } +} + +func TestTheWrongNumberOfArgumentsIsRejected(t *testing.T) { + for _, args := range [][]string{ + {}, + {"only-one"}, + {"a/b", "1", "extra"}, + } { + _, err := Parse(args) + if err == nil { + t.Fatalf("accepted %d arguments", len(args)) + } + if err.Code != ExitUsage { + t.Errorf("%v: Code = %d, want %d", args, err.Code, ExitUsage) + } + if !contains(err.Msg, "usage:") { + t.Errorf("%v: Msg = %q", args, err.Msg) + } + } +} + +// 🔴 THE NEGATIVE CONTROL ON THE VALIDATOR, BUILT FROM REALISTIC DATA. A +// rejector that refused EVERYTHING would pass every rejection test above — +// which is the instrument-validation trap. Dots, dashes, underscores and mixed +// case all occur in real owner and repository names. +func TestRealShapedRepositoryNamesAreACCEPTED(t *testing.T) { + for _, repo := range []string{ + "gardenersguild/trowelcast", + "rivalorg/spadeworks", + "innovation-upstream/devrc", + "a-b.c/d_e.f", + "Mixed-Case/Repo.Name_2", + } { + got, err := Parse([]string{repo, "7"}) + if err != nil { + t.Errorf("rejected %q: %v", repo, err.Msg) + continue + } + if got.Repo() != repo { + t.Errorf("round trip: %q -> %q", repo, got.Repo()) + } + if got.Num != 7 { + t.Errorf("%q: Num = %d, want 7", repo, got.Num) + } + } +} + +// ⚠ `"0"` IS ACCEPTED, MATCHING THE SHELL CONTRACT. `nvim-octo.sh` rejects only +// the empty string and non-digits, so `0` reaches octo today. This test pins +// the AGREEMENT rather than an improvement: two implementations of one contract +// disagreeing about a reachable input is worse than being marginally stricter. +func TestZeroIsAcceptedBecauseTheShellContractAcceptsIt(t *testing.T) { + got, err := Parse([]string{"rivalorg/spadeworks", "0"}) + if err != nil { + t.Fatalf("rejected %q, which nvim-octo.sh accepts: %v", "0", err.Msg) + } + if got.Num != 0 { + t.Errorf("Num = %d, want 0", got.Num) + } +} + +// The one deliberate divergence, pinned so it cannot become accidental. +func TestAnOverflowingNumberIsRejectedRatherThanWrapped(t *testing.T) { + _, err := Parse([]string{"rivalorg/spadeworks", "99999999999999999999999"}) + if err == nil { + t.Fatal("accepted a number that cannot fit in an int") + } + if err.Code != ExitBadNum { + t.Errorf("Code = %d, want %d", err.Code, ExitBadNum) + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0 +} + +func indexOf(h, n string) int { + for i := 0; i+len(n) <= len(h); i++ { + if h[i:i+len(n)] == n { + return i + } + } + return -1 +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ghapi/auth.go b/nix/pkgs/tools/mention-review/src/internal/ghapi/auth.go new file mode 100644 index 000000000..8018681b2 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ghapi/auth.go @@ -0,0 +1,104 @@ +// Package ghapi is the GitHub client: token resolution, ONE GraphQL read, and +// the REST diff read. It owns every network call this binary makes. +package ghapi + +import "net/http" + +// AuthState is the classification §6.1 requires the failure cards to be able to +// make. 🔴 `NO TOKEN` and `TOKEN REJECTED` are explicitly distinguished, +// because the fixes differ: one is `gh auth login`, the other is a token that +// exists and was refused. +type AuthState int + +const ( + AuthOK AuthState = iota + // AuthNoToken — go-gh resolved nothing. Note its fourth rung returns + // `("", "default")` with NO ERROR, so an empty string is the only signal + // there is; a caller that checks only `err` sees success. + AuthNoToken + // AuthRejected — a token was resolved and GitHub answered 401. + AuthRejected + // AuthNotFound — 404. Separated because a browser session may have access + // this token does not, so the card offers `o` (§6.1). + AuthNotFound + // AuthRateLimited — 403/429 with a rate-limit signal. + AuthRateLimited + // AuthOther — reached the server, got something else. + AuthOther +) + +// Word is the WORD this state renders as. 🔴 Every meaning-bearing state is a +// WORD and colour is decoration on top of it, never the carrier (§3.1). The +// operator's font renders the red/yellow/green severity circles as one +// indistinguishable glyph, so a coloured dot carries nothing. +func (s AuthState) Word() string { + switch s { + case AuthOK: + return "OK" + case AuthNoToken: + return "NO TOKEN" + case AuthRejected: + return "TOKEN REJECTED" + case AuthNotFound: + return "NOT FOUND" + case AuthRateLimited: + return "RATE LIMITED" + } + return "ERROR" +} + +// Hint is the fix, in words, for each state. Never the token, never a header, +// never a URL with a credential in it (§10.4). +func (s AuthState) Hint() string { + switch s { + case AuthNoToken: + return "run `gh auth login`" + case AuthRejected: + return "the token exists but GitHub refused it" + case AuthNotFound: + return "not visible to this token — `o` opens it in the browser" + case AuthRateLimited: + return "wait for the reset shown above" + } + return "" +} + +// Classify maps a resolved token plus an HTTP status onto the card §6.1 draws. +// +// 🔴 PURE, AND TAKING THE TOKEN'S EMPTINESS RATHER THAN THE TOKEN. It never +// sees a credential, so no test of it can leak one and no error path built on +// it can print one. +func Classify(haveToken bool, statusCode int) AuthState { + if !haveToken { + return AuthNoToken + } + switch statusCode { + case http.StatusOK: + return AuthOK + case http.StatusUnauthorized: + return AuthRejected + case http.StatusNotFound: + return AuthNotFound + case http.StatusForbidden, http.StatusTooManyRequests: + // 🔴 403 IS AMBIGUOUS AND IS NOT ASSUMED TO BE RATE LIMITING. GitHub + // answers 403 both for a spent rate limit and for a token whose scopes + // do not cover the request. The caller passes the rate-limit signal + // separately via ClassifyResponse; this arm is the conservative + // default for a bare status code. + return AuthRateLimited + } + return AuthOther +} + +// ClassifyResponse is Classify with the one header that disambiguates 403. +// `remaining` is the value of `x-ratelimit-remaining`, or -1 when absent. +func ClassifyResponse(haveToken bool, statusCode int, remaining int) AuthState { + s := Classify(haveToken, statusCode) + if s == AuthRateLimited && statusCode == http.StatusForbidden && remaining != 0 { + // A 403 with budget left is a SCOPE problem, not a rate limit. Calling + // it RATE LIMITED would send the operator to wait for a reset that is + // not coming. + return AuthOther + } + return s +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ghapi/diff.go b/nix/pkgs/tools/mention-review/src/internal/ghapi/diff.go new file mode 100644 index 000000000..42292b238 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ghapi/diff.go @@ -0,0 +1,90 @@ +package ghapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +// restFile is the shape of one entry from `GET /pulls/{n}/files`. +// +// 🔴 GRAPHQL CANNOT RETURN PATCH TEXT, which is the whole reason this second +// endpoint exists. MEASURED: `GET /pulls/N/files?per_page=100` costs ~0.66 s +// and carried a `patch` for 23/23 files on the largest PR in this repo — but +// that is ONE PR, not a guarantee. The API omits `patch` for binary files and +// caps it for very large ones, so an absent patch is a first-class state +// rendered as the WORD `NO PATCH`, never as an empty diff. +type restFile struct { + Filename string `json:"filename"` + Status string `json:"status"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Patch string `json:"patch"` + PreviousFilename string `json:"previous_filename"` +} + +// FetchFiles reads the per-file patches for a pull request. +// +// ⚠ ONE PAGE, DELIBERATELY, IN PHASE 1 — matching the GraphQL read's cap so the +// two halves cannot disagree about how many files exist. `truncated` is +// returned rather than inferred, and the Files panel says so in words. +func (c *Client) FetchFiles(ctx context.Context, owner, name string, num int) (files []File, truncated bool, err error) { + const perPage = 100 + url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/files?per_page=%d", c.rest, owner, name, num, perPage) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, false, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + body, err := c.do(ctx, req) + if err != nil { + return nil, false, err + } + var raw []restFile + if err := json.Unmarshal(body, &raw); err != nil { + return nil, false, &APIError{State: AuthOther, Detail: "unreadable files response: " + err.Error()} + } + for _, f := range raw { + files = append(files, File{ + Path: f.Filename, + Additions: f.Additions, + Deletions: f.Deletions, + ChangeType: restStatusToChangeType(f.Status), + Patch: f.Patch, + PreviousPath: f.PreviousFilename, + }) + } + return files, len(raw) == perPage, nil +} + +// restStatusToChangeType normalises REST's lowercase `status` onto the same +// vocabulary GraphQL's `changeType` uses. +// +// 🔴 ONE VOCABULARY, ONE PLACE. The two endpoints spell the same fact +// differently ("removed" vs "REMOVED", "added" vs "ADDED"), and a predicate +// open-coded at each reader is wrong at all but one of them in the same +// direction. The Files panel branches on ONE set of words, defined here. +func restStatusToChangeType(s string) string { + switch s { + case "added": + return "ADDED" + case "removed": + return "REMOVED" + case "modified": + return "MODIFIED" + case "renamed": + return "RENAMED" + case "copied": + return "COPIED" + case "changed": + return "CHANGED" + case "unchanged": + return "UNCHANGED" + } + // 🔴 An unknown status is reported as itself, uppercased by the caller's + // eye rather than mapped onto a guess. A silent fallback to MODIFIED would + // make a new GitHub status render as an ordinary edit. + return s +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ghapi/ghapi_test.go b/nix/pkgs/tools/mention-review/src/internal/ghapi/ghapi_test.go new file mode 100644 index 000000000..ebde19c78 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ghapi/ghapi_test.go @@ -0,0 +1,451 @@ +package ghapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// 🔴 EVERY FIXTURE HERE IS SYNTHETIC — this repository is PUBLIC and captured +// text must not land in it in any form, fixtures included. The shapes are real; +// the values are invented. +// +// 🔴 EXPECTED VALUES ARE WRITTEN BY HAND FROM THE GRAPHQL SCHEMA AND THE CARD +// SPEC, NEVER READ OFF THE DECODER. + +// --- Classify: the states must be DISTINGUISHABLE ---------------------------- + +func TestClassifyDistinguishesNoTokenFromRejected(t *testing.T) { + cases := []struct { + name string + haveToken bool + status int + want AuthState + }{ + {"no token at all", false, 0, AuthNoToken}, + // 🔴 AND NO TOKEN WINS EVEN OVER A 200. An empty token cannot have + // produced a successful authenticated response, so a classifier that + // checked the status first would report OK for a request that was never + // authenticated. + {"no token, absurd 200", false, http.StatusOK, AuthNoToken}, + {"token accepted", true, http.StatusOK, AuthOK}, + {"token refused", true, http.StatusUnauthorized, AuthRejected}, + {"not visible", true, http.StatusNotFound, AuthNotFound}, + {"too many requests", true, http.StatusTooManyRequests, AuthRateLimited}, + {"something else", true, http.StatusBadGateway, AuthOther}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := Classify(c.haveToken, c.status); got != c.want { + t.Errorf("Classify(%v,%d) = %v, want %v", c.haveToken, c.status, got, c.want) + } + }) + } + // The property the §6 cards depend on, asserted directly: the two words + // differ, because the FIXES differ. + if AuthNoToken.Word() == AuthRejected.Word() { + t.Fatal("NO TOKEN and TOKEN REJECTED are the same word") + } +} + +// ⚠ 403 IS AMBIGUOUS AND IS NOT ASSUMED TO BE RATE LIMITING. GitHub answers 403 +// both for a spent budget and for a token whose scopes do not cover the +// request. Calling a scope failure RATE LIMITED would send the operator to wait +// for a reset that is not coming. +func TestA403WithBudgetLeftIsNotCalledRateLimited(t *testing.T) { + if got := ClassifyResponse(true, http.StatusForbidden, 0); got != AuthRateLimited { + t.Errorf("403 with remaining=0 = %v, want AuthRateLimited", got) + } + if got := ClassifyResponse(true, http.StatusForbidden, 4231); got != AuthOther { + t.Errorf("403 with remaining=4231 = %v, want AuthOther", got) + } + // An absent header (-1) is UNKNOWN, not zero — it must not be read as a + // spent budget. + if got := ClassifyResponse(true, http.StatusForbidden, -1); got != AuthOther { + t.Errorf("403 with no rate-limit header = %v, want AuthOther", got) + } + // 429 is unambiguous and stays RATE LIMITED whatever the header says. + if got := ClassifyResponse(true, http.StatusTooManyRequests, 4231); got != AuthRateLimited { + t.Errorf("429 = %v, want AuthRateLimited", got) + } +} + +// --- decodeSnapshot ---------------------------------------------------------- + +const prFixture = `{"data":{ + "viewer":{"login":"a-reviewer"}, + "repository":{"issueOrPullRequest":{ + "__typename":"PullRequest", + "number":1559, + "title":"Refresh the stale context before running", + "state":"OPEN","isDraft":false,"merged":false, + "url":"https://github.com/gardenersguild/trowelcast/pull/1559", + "createdAt":"2026-09-10T09:00:00Z","updatedAt":"2026-09-12T17:30:00Z", + "author":{"login":"an-author"}, + "baseRefName":"main","headRefName":"fix/stale-context", + "additions":312,"deletions":40,"changedFiles":2, + "mergeable":"MERGEABLE","mergeStateStatus":"BLOCKED", + "reviewDecision":null, + "commits":{"totalCount":3,"nodes":[ + {"commit":{"oid":"a1b2c3d4","abbreviatedOid":"a1b2c3d","messageHeadline":"refresh the stale context","committedDate":"2026-09-10T09:05:00Z","author":{"name":"An Author"}}}, + {"commit":{"oid":"e4f5a6b7","abbreviatedOid":"e4f5a6b","messageHeadline":"address review","committedDate":"2026-09-11T11:00:00Z","author":{"name":"An Author"}}}, + {"commit":{"oid":"c8d9e0f1","abbreviatedOid":"c8d9e0f","messageHeadline":"add the regression test","committedDate":"2026-09-12T17:00:00Z","author":null}} + ]}, + "files":{"totalCount":2,"pageInfo":{"hasNextPage":false},"nodes":[ + {"path":"pkg/handler.go","additions":9,"deletions":1,"changeType":"MODIFIED"}, + {"path":"pkg/widget.go","additions":3,"deletions":0,"changeType":"ADDED"} + ]}, + "reviews":{"totalCount":1,"nodes":[ + {"author":{"login":"a-reviewer"},"state":"CHANGES_REQUESTED","submittedAt":"2026-09-11T10:00:00Z"} + ]}, + "reviewThreads":{"totalCount":7,"nodes":[ + {"isResolved":false},{"isResolved":false},{"isResolved":false}, + {"isResolved":true},{"isResolved":true},{"isResolved":true},{"isResolved":true} + ]}, + "rollup":{"nodes":[{"commit":{"statusCheckRollup":{ + "state":"FAILURE", + "contexts":{"totalCount":7,"nodes":[ + {"__typename":"CheckRun","name":"build","conclusion":"FAILURE","status":"COMPLETED"}, + {"__typename":"CheckRun","name":"lint","conclusion":"FAILURE","status":"COMPLETED"}, + {"__typename":"StatusContext","context":"legacy","state":"FAILURE"}, + {"__typename":"CheckRun","name":"slow","conclusion":"","status":"IN_PROGRESS"}, + {"__typename":"StatusContext","context":"waiting","state":"PENDING"}, + {"__typename":"CheckRun","name":"unit","conclusion":"SUCCESS","status":"COMPLETED"}, + {"__typename":"StatusContext","context":"ok","state":"SUCCESS"} + ]} + }}}]} + }} +}}` + +func TestDecodeAPullRequestPopulatesEveryPanel(t *testing.T) { + s, err := decodeSnapshot([]byte(prFixture), "gardenersguild/trowelcast", 1559) + if err != nil { + t.Fatal(err) + } + + // 🔴 §10.2 — the viewer login rides the SAME round trip and must survive + // decoding. It is the multi-account mitigation, and it is not optional. + if s.ViewerLogin != "a-reviewer" { + t.Errorf("ViewerLogin = %q, want %q", s.ViewerLogin, "a-reviewer") + } + if s.Kind != KindPullRequest { + t.Errorf("Kind = %q", s.Kind) + } + if s.Title != "Refresh the stale context before running" { + t.Errorf("Title = %q", s.Title) + } + if s.Additions != 312 || s.Deletions != 40 || s.ChangedFiles != 2 { + t.Errorf("counts = +%d -%d over %d files", s.Additions, s.Deletions, s.ChangedFiles) + } + if s.Mergeable != "MERGEABLE" || s.MergeStateStatus != "BLOCKED" { + t.Errorf("merge = %q/%q", s.Mergeable, s.MergeStateStatus) + } + // 🔴 A NULL reviewDecision DECODES TO "", NOT TO A GUESS. It is what the + // server really returns for a PR nobody has reviewed — measured on a real + // PR — and the UI renders it as the WORD `NONE`. + if s.ReviewDecision != "" { + t.Errorf("ReviewDecision = %q, want empty for a null", s.ReviewDecision) + } + + if len(s.Commits) != 3 { + t.Fatalf("Commits = %d, want 3", len(s.Commits)) + } + if s.Commits[0].Abbrev != "a1b2c3d" || s.Commits[0].Headline != "refresh the stale context" { + t.Errorf("Commits[0] = %+v", s.Commits[0]) + } + // A deleted account decodes to "", not to a panic. + if s.Commits[2].Author != "" { + t.Errorf("a null author decoded to %q", s.Commits[2].Author) + } + if s.CommitsTruncated { + t.Error("CommitsTruncated is set on a 3-of-3 page") + } + + if len(s.Files) != 2 || s.Files[1].Path != "pkg/widget.go" || s.Files[1].ChangeType != "ADDED" { + t.Errorf("Files = %+v", s.Files) + } + if s.FilesTruncated { + t.Error("FilesTruncated is set on a 2-of-2 page") + } + + // Counted BY HAND off the fixture: 7 threads, 3 of them unresolved. + if s.Threads.Total != 7 || s.Threads.Unresolved != 3 { + t.Errorf("Threads = %+v, want {Total:7 Unresolved:3}", s.Threads) + } + // Counted BY HAND off the contexts: 2 failing CheckRuns + 1 failing + // StatusContext = 3 failing; 1 IN_PROGRESS CheckRun + 1 PENDING + // StatusContext = 2 pending. + if s.Checks.Failing != 3 { + t.Errorf("Checks.Failing = %d, want 3", s.Checks.Failing) + } + if s.Checks.Pending != 2 { + t.Errorf("Checks.Pending = %d, want 2", s.Checks.Pending) + } + if s.Checks.Total != 7 { + t.Errorf("Checks.Total = %d, want 7", s.Checks.Total) + } + if s.Checks.State != "FAILURE" { + t.Errorf("Checks.State = %q", s.Checks.State) + } +} + +// 🔴 `hasNextPage` IS CARRIED, NOT INFERRED, so a PR past the 100-file cap says +// so in words instead of showing a list that LOOKS complete. +func TestAPagedFileListIsMarkedTruncated(t *testing.T) { + var doc map[string]any + if err := json.Unmarshal([]byte(prFixture), &doc); err != nil { + t.Fatal(err) + } + node := doc["data"].(map[string]any)["repository"].(map[string]any)["issueOrPullRequest"].(map[string]any) + node["files"].(map[string]any)["pageInfo"].(map[string]any)["hasNextPage"] = true + raw, _ := json.Marshal(doc) + + s, err := decodeSnapshot(raw, "gardenersguild/trowelcast", 1559) + if err != nil { + t.Fatal(err) + } + if !s.FilesTruncated { + t.Error("hasNextPage=true did not set FilesTruncated") + } + // And the negative control, from the unmodified fixture, so "true" above is + // a claim about the field rather than about a constant. + s2, _ := decodeSnapshot([]byte(prFixture), "gardenersguild/trowelcast", 1559) + if s2.FilesTruncated { + t.Error("the unmodified fixture also reports truncated — the field is ignored") + } +} + +const issueFixture = `{"data":{ + "viewer":{"login":"a-reviewer"}, + "repository":{"issueOrPullRequest":{ + "__typename":"Issue", + "number":1656, + "title":"Widget refresh drops the stale context", + "state":"OPEN", + "body":"The widget keeps a context past its refresh window.", + "url":"https://github.com/gardenersguild/trowelcast/issues/1656", + "createdAt":"2026-09-13T08:00:00Z","updatedAt":"2026-09-13T08:00:00Z", + "author":{"login":"an-author"} + }} +}}` + +// 🔴 THE SERVER ANSWERS THE ISSUE-VS-PR QUESTION, IN THE SAME ROUND TRIP. +// `mention-open.py` builds `/pull/{id}` for every mention and cannot know the +// kind — and must not find out, because it carries a test-pinned property that +// THE CLICK PATH MAKES NO NETWORK CALL. +func TestDecodeAnIssueYieldsTheCardFieldsAndNoPRFields(t *testing.T) { + s, err := decodeSnapshot([]byte(issueFixture), "gardenersguild/trowelcast", 1656) + if err != nil { + t.Fatal(err) + } + if s.Kind != KindIssue { + t.Fatalf("Kind = %q, want Issue", s.Kind) + } + if s.Body == "" || s.Title == "" || s.Author == "" { + t.Errorf("the card fields are incomplete: %+v", s) + } + if s.ViewerLogin != "a-reviewer" { + t.Errorf("ViewerLogin = %q", s.ViewerLogin) + } + // The PR-only fields stay zero rather than carrying stale or invented data. + if len(s.Commits) != 0 || len(s.Files) != 0 || s.Additions != 0 { + t.Errorf("an issue decoded PR fields: %+v", s) + } +} + +func TestANullRepositoryIsReportedAsNotFound(t *testing.T) { + s, err := decodeSnapshot([]byte(`{"data":{"viewer":{"login":"x"},"repository":null}}`), + "gardenersguild/trowelcast", 1559) + if s != nil { + t.Errorf("returned a snapshot for a null repository: %+v", s) + } + var ae *APIError + if !errors.As(err, &ae) { + t.Fatalf("err is %T, want *APIError", err) + } + if ae.State != AuthNotFound { + t.Errorf("State = %v, want AuthNotFound", ae.State) + } + if !strings.Contains(ae.Detail, "gardenersguild/trowelcast#1559") { + t.Errorf("Detail does not name the reference: %q", ae.Detail) + } +} + +func TestAGraphQLNotFoundErrorIsReportedAsNotFound(t *testing.T) { + body := `{"errors":[{"type":"NOT_FOUND","message":"Could not resolve to a Repository"}]}` + _, err := decodeSnapshot([]byte(body), "gardenersguild/trowelcast", 1559) + var ae *APIError + if !errors.As(err, &ae) || ae.State != AuthNotFound { + t.Fatalf("err = %v, want a NOT_FOUND APIError", err) + } +} + +// --- the client, against a FAKE server --------------------------------------- + +func TestFetchAgainstAFakeServerDecodesAndReusesOneRoundTrip(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if got := r.Header.Get("Authorization"); got != "bearer a-test-token" { + t.Errorf("Authorization = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(prFixture)) + })) + defer srv.Close() + + c := NewClient("a-test-token", srv.Client()) + c.SetBaseURLs(srv.URL+"/graphql", srv.URL) + + s, err := c.Fetch(context.Background(), "gardenersguild", "trowelcast", 1559) + if err != nil { + t.Fatal(err) + } + if s.ViewerLogin != "a-reviewer" { + t.Errorf("ViewerLogin = %q", s.ViewerLogin) + } + // 🔴 ONE ROUND TRIP. This is the Phase-0 kill criterion, pinned so a later + // change that splits the query into two reads fails the suite rather than + // quietly halving the tool's headline property. + if calls != 1 { + t.Errorf("Fetch made %d HTTP calls, want exactly 1", calls) + } +} + +// 🔴 AN EMPTY TOKEN NEVER REACHES THE NETWORK, AND IT PRODUCES `NO TOKEN`, NOT +// A 401. go-gh's fourth precedence rung returns ("", "default") with NO error, +// so an empty token is a VALUE rather than a failure — a client that only +// checked `err` would send an unauthenticated request and render a card naming +// the wrong fix. +func TestAnEmptyTokenShortCircuitsBeforeTheNetwork(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + c := NewClient("", srv.Client()) + c.SetBaseURLs(srv.URL+"/graphql", srv.URL) + + _, err := c.Fetch(context.Background(), "gardenersguild", "trowelcast", 1559) + var ae *APIError + if !errors.As(err, &ae) { + t.Fatalf("err = %v, want an APIError", err) + } + if ae.State != AuthNoToken { + t.Errorf("State = %v, want AuthNoToken", ae.State) + } + if calls != 0 { + t.Errorf("an empty token still made %d request(s)", calls) + } + // POSITIVE CONTROL on the counter: a NON-empty token must reach the server, + // or `calls == 0` proves nothing about the short circuit. + c2 := NewClient("a-test-token", srv.Client()) + c2.SetBaseURLs(srv.URL+"/graphql", srv.URL) + _, err2 := c2.Fetch(context.Background(), "gardenersguild", "trowelcast", 1559) + if calls == 0 { + t.Fatal("a real token ALSO made no request — the counter is wired to nothing") + } + var ae2 *APIError + if !errors.As(err2, &ae2) || ae2.State != AuthRejected { + t.Errorf("a 401 with a token = %v, want TOKEN REJECTED", err2) + } +} + +// 🔴 THE TOKEN IS NEVER IN AN ERROR. §10.4: the cards say which condition holds +// and nothing more. +func TestNoErrorPathEverCarriesTheToken(t *testing.T) { + const secret = "gho_thisisnotarealtokenitisatestfixture" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + // Even a server that echoes the credential back must not get it into + // an error the UI renders. + _, _ = w.Write([]byte(`{"message":"Bad credentials for ` + secret + `"}`)) + })) + defer srv.Close() + + c := NewClient(secret, srv.Client()) + c.SetBaseURLs(srv.URL+"/graphql", srv.URL) + _, err := c.Fetch(context.Background(), "gardenersguild", "trowelcast", 1559) + if err == nil { + t.Fatal("expected an error") + } + // ⚠ THE SERVER ECHOED IT, SO THIS *CAN* FAIL — which is what makes the + // assertion evidence rather than a tautology. The client must not reflect + // the credential out of a response body it did not construct. + if strings.Contains(err.Error(), secret) { + t.Errorf("the token leaked into an error string: %q", err.Error()) + } + // The positive control: the error is not empty, so "does not contain" is + // not satisfied by there being no error text at all. + if err.Error() == "" { + t.Error("the error is empty — the leak check observed nothing") + } +} + +// --- the REST diff read ------------------------------------------------------ + +func TestFetchFilesNormalisesRESTStatusOntoOneVocabulary(t *testing.T) { + const files = `[ + {"filename":"pkg/handler.go","status":"modified","additions":9,"deletions":1,"patch":"@@ -1,1 +1,2 @@\n a\n+b"}, + {"filename":"pkg/widget.go","status":"added","additions":3,"deletions":0,"patch":"@@ -0,0 +1,3 @@\n+a\n+b\n+c"}, + {"filename":"pkg/old.go","status":"removed","additions":0,"deletions":2,"patch":"@@ -1,2 +0,0 @@\n-a\n-b"}, + {"filename":"pkg/new_name.go","status":"renamed","previous_filename":"pkg/old_name.go","additions":0,"deletions":0}, + {"filename":"assets/logo.png","status":"modified","additions":0,"deletions":0} + ]` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.RawQuery, "per_page=100") { + t.Errorf("query = %q, want per_page=100", r.URL.RawQuery) + } + _, _ = w.Write([]byte(files)) + })) + defer srv.Close() + + c := NewClient("a-test-token", srv.Client()) + c.SetBaseURLs(srv.URL+"/graphql", srv.URL) + + got, truncated, err := c.FetchFiles(context.Background(), "gardenersguild", "trowelcast", 1559) + if err != nil { + t.Fatal(err) + } + if truncated { + t.Error("a 5-file page is marked truncated") + } + // 🔴 ONE VOCABULARY. The two endpoints spell the same fact differently + // ("removed" vs "REMOVED"); a predicate open-coded at each reader is wrong + // at all but one of them, in the same direction. + want := []string{"MODIFIED", "ADDED", "REMOVED", "RENAMED", "MODIFIED"} + for i, w := range want { + if got[i].ChangeType != w { + t.Errorf("file %d ChangeType = %q, want %q", i, got[i].ChangeType, w) + } + } + if got[3].PreviousPath != "pkg/old_name.go" { + t.Errorf("rename lost its previous path: %+v", got[3]) + } + // 🔴 A MISSING PATCH IS AN EMPTY STRING, not an error and not a fabricated + // empty diff. §6.1 renders it as the WORD `NO PATCH`. + if got[4].Patch != "" { + t.Errorf("a patchless file carries a patch: %q", got[4].Patch) + } + if got[0].Patch == "" { + t.Error("a file WITH a patch lost it — the field is not being read") + } +} + +// An unknown REST status is reported as ITSELF rather than mapped onto a guess. +// A silent fallback to MODIFIED would make a new GitHub status render as an +// ordinary edit. +func TestAnUnknownRESTStatusIsNotMappedToModified(t *testing.T) { + if got := restStatusToChangeType("teleported"); got == "MODIFIED" { + t.Error("an unknown status was mapped onto MODIFIED") + } + if got := restStatusToChangeType("teleported"); got != "teleported" { + t.Errorf("= %q, want the status echoed back", got) + } +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ghapi/query.go b/nix/pkgs/tools/mention-review/src/internal/ghapi/query.go new file mode 100644 index 000000000..41633c8f4 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ghapi/query.go @@ -0,0 +1,468 @@ +package ghapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// Query is the ONE GraphQL read. 🔴 `issueOrPullRequest(number:)` with an +// inline fragment per type returns `__typename` — the issue-vs-PR answer — PLUS +// every field the four panels need, in a single round trip. +// +// MEASURED in a Go probe on 2026-09-14 against the public +// `innovation-upstream/devrc`: 1 HTTP round trip, median 663 ms on a 23-file PR, +// 536 ms on a small one, 402 ms on an issue. Not one field was ABSENT; the only +// null was `reviewDecision`, which is a legitimate "no decision yet" state the +// Overview panel renders as a WORD. +// +// ⚠ ONE KNOWN PAGINATION EDGE, AND IT IS SURFACED RATHER THAN HIDDEN: +// `files(first:100)` and `commits(last:100)` cap at one page. `pageInfo` is +// requested so a PR past either cap sets Snapshot.FilesTruncated / +// CommitsTruncated and the panel says so in words. A second round trip for the +// tail is deliberately NOT in Phase 1 — the p90 is 6 changed files in this repo +// and 19 in the second one measured, so the cap is the far tail, and an +// unmarked short list is the only outcome that would be dishonest. +const Query = ` +query($owner:String!,$name:String!,$number:Int!){ + viewer { login } + repository(owner:$owner,name:$name){ + issueOrPullRequest(number:$number){ + __typename + ... on Issue { + number title state body url createdAt updatedAt + author{login} + } + ... on PullRequest { + number title state isDraft merged body url createdAt updatedAt + author{login} + baseRefName headRefName + additions deletions changedFiles + mergeable mergeStateStatus reviewDecision + commits(last:100){ + totalCount + nodes{ commit{ oid abbreviatedOid messageHeadline committedDate author{name} } } + } + files(first:100){ + totalCount + pageInfo{ hasNextPage } + nodes{ path additions deletions changeType } + } + reviews(last:50){ + totalCount + nodes{ author{login} state submittedAt } + } + reviewThreads(first:100){ + totalCount + nodes{ isResolved } + } + rollup: commits(last:1){ + nodes{ commit{ statusCheckRollup{ + state + contexts(first:100){ + totalCount + nodes{ + __typename + ... on CheckRun{ name conclusion status } + ... on StatusContext{ context state } + } + } + } } } + } + } + } + } +}` + +// APIError is a transport- or auth-level failure the UI renders as a card. +// 🔴 It carries a STATE, not a bare string, because §6.1's cards differ by +// state and a string would make the UI re-parse prose to choose one. +type APIError struct { + State AuthState + // Detail is the underlying message. 🔴 NEVER the token and never a header + // value — Client.do builds it from the status line and the API's own + // `message` field only. + Detail string + // ResetAt is set only for AuthRateLimited, from the response header. + ResetAt time.Time +} + +func (e *APIError) Error() string { + if e.Detail == "" { + return e.State.Word() + } + return e.State.Word() + " — " + e.Detail +} + +// Client is built once at startup over ONE http.Client so every call after the +// first reuses the connection (§3.5). MEASURED: a cold authenticated round trip +// to api.github.com costs ~0.30 s and a warm one ~0.17 s. +type Client struct { + http *http.Client + token string + endpoint string // GraphQL endpoint; overridable so tests can point at a fake + rest string // REST base; same + ua string +} + +// NewClient takes the token as a value rather than resolving it, so the whole +// package is testable without touching the host's gh config. +func NewClient(token string, hc *http.Client) *Client { + if hc == nil { + hc = &http.Client{Timeout: 30 * time.Second} + } + return &Client{ + http: hc, + token: token, + endpoint: "https://api.github.com/graphql", + rest: "https://api.github.com", + ua: "mention-review", + } +} + +// SetBaseURLs points the client at a fake server. Test seam only. +func (c *Client) SetBaseURLs(graphql, rest string) { + c.endpoint, c.rest = graphql, rest +} + +func (c *Client) do(ctx context.Context, req *http.Request) ([]byte, error) { + if c.token == "" { + // 🔴 The check is here and not only at startup, because go-gh's fourth + // rung returns `("", "default")` with NO error — an empty token is a + // value, not a failure, and it would otherwise produce a 401 card + // naming the wrong fix. + return nil, &APIError{State: AuthNoToken} + } + req.Header.Set("Authorization", "bearer "+c.token) + req.Header.Set("User-Agent", c.ua) + resp, err := c.http.Do(req.WithContext(ctx)) + if err != nil { + return nil, &APIError{State: AuthOther, Detail: "could not reach api.github.com: " + err.Error()} + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + + remaining := -1 + if v := resp.Header.Get("x-ratelimit-remaining"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + remaining = n + } + } + st := ClassifyResponse(true, resp.StatusCode, remaining) + if st != AuthOK { + e := &APIError{State: st, Detail: c.redact(apiMessage(body, resp.Status))} + if st == AuthRateLimited { + if v := resp.Header.Get("x-ratelimit-reset"); v != "" { + if sec, err := strconv.ParseInt(v, 10, 64); err == nil { + e.ResetAt = time.Unix(sec, 0) + } + } + } + return nil, e + } + return body, nil +} + +// redact removes the token from any string that is about to become an error +// the UI renders. +// +// 🔴 THIS IS NOT BELT-AND-BRACES — IT CLOSES A MEASURED HOLE. `apiMessage` +// reflects the SERVER's own `message` field, which this code did not construct +// and cannot vouch for. A test that made the fake server echo the credential +// back watched the token come out the other side inside +// `TOKEN REJECTED — Bad credentials for gho_…`. GitHub does not do that today; +// nothing guarantees that it, a proxy, or an enterprise gateway never will, and +// §10.4 says the token is never logged, never cached and never included in any +// error card. The check is STRUCTURAL — it looks for the exact secret this +// client holds — rather than a pattern match on things that look like tokens. +func (c *Client) redact(s string) string { + if c.token == "" { + return s + } + return strings.ReplaceAll(s, c.token, "") +} + +// apiMessage pulls GitHub's own `message` out of an error body. Falls back to +// the status line. 🔴 It never reflects a request header back. +func apiMessage(body []byte, status string) string { + var m struct { + Message string `json:"message"` + } + if json.Unmarshal(body, &m) == nil && m.Message != "" { + return m.Message + } + return status +} + +// Fetch performs the one GraphQL read and decodes it into a Snapshot. +func (c *Client) Fetch(ctx context.Context, owner, name string, num int) (*Snapshot, error) { + payload, err := json.Marshal(map[string]any{ + "query": Query, + "variables": map[string]any{ + "owner": owner, "name": name, "number": num, + }, + }) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, c.endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + body, err := c.do(ctx, req) + if err != nil { + return nil, err + } + return decodeSnapshot(body, owner+"/"+name, num) +} + +// --- decoding --------------------------------------------------------------- + +type gqlResponse struct { + Data struct { + Viewer struct { + Login string `json:"login"` + } `json:"viewer"` + Repository *struct { + Node *gqlNode `json:"issueOrPullRequest"` + } `json:"repository"` + } `json:"data"` + Errors []struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"errors"` +} + +type gqlActor struct { + Login string `json:"login"` + Name string `json:"name"` +} + +type gqlNode struct { + Typename string `json:"__typename"` + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Body string `json:"body"` + URL string `json:"url"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Author *gqlActor `json:"author"` + + IsDraft bool `json:"isDraft"` + Merged bool `json:"merged"` + BaseRefName string `json:"baseRefName"` + HeadRefName string `json:"headRefName"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ChangedFiles int `json:"changedFiles"` + Mergeable string `json:"mergeable"` + MergeStateStatus string `json:"mergeStateStatus"` + ReviewDecision *string `json:"reviewDecision"` + + Commits struct { + TotalCount int `json:"totalCount"` + Nodes []struct { + Commit struct { + OID string `json:"oid"` + AbbreviatedOID string `json:"abbreviatedOid"` + Headline string `json:"messageHeadline"` + CommittedDate time.Time `json:"committedDate"` + Author *gqlActor `json:"author"` + } `json:"commit"` + } `json:"nodes"` + } `json:"commits"` + + Files struct { + TotalCount int `json:"totalCount"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + } `json:"pageInfo"` + Nodes []struct { + Path string `json:"path"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ChangeType string `json:"changeType"` + } `json:"nodes"` + } `json:"files"` + + Reviews struct { + TotalCount int `json:"totalCount"` + Nodes []struct { + Author *gqlActor `json:"author"` + State string `json:"state"` + SubmittedAt time.Time `json:"submittedAt"` + } `json:"nodes"` + } `json:"reviews"` + + ReviewThreads struct { + TotalCount int `json:"totalCount"` + Nodes []struct { + IsResolved bool `json:"isResolved"` + } `json:"nodes"` + } `json:"reviewThreads"` + + Rollup struct { + Nodes []struct { + Commit struct { + StatusCheckRollup *struct { + State string `json:"state"` + Contexts struct { + TotalCount int `json:"totalCount"` + Nodes []struct { + Typename string `json:"__typename"` + Conclusion string `json:"conclusion"` + Status string `json:"status"` + State string `json:"state"` + } `json:"nodes"` + } `json:"contexts"` + } `json:"statusCheckRollup"` + } `json:"commit"` + } `json:"nodes"` + } `json:"rollup"` +} + +// ErrNotFound is returned when the reference does not resolve. 🔴 A GraphQL +// 200 with `repository: null` is the SAME user-facing condition as a REST 404 +// — "not visible to this token" — and collapsing them here is what stops the +// UI needing two code paths for one meaning. +func decodeSnapshot(body []byte, repo string, num int) (*Snapshot, error) { + var r gqlResponse + if err := json.Unmarshal(body, &r); err != nil { + return nil, &APIError{State: AuthOther, Detail: "unreadable response: " + err.Error()} + } + if len(r.Errors) > 0 { + st := AuthOther + if r.Errors[0].Type == "NOT_FOUND" { + st = AuthNotFound + } + return nil, &APIError{State: st, Detail: r.Errors[0].Message} + } + if r.Data.Repository == nil || r.Data.Repository.Node == nil { + return nil, &APIError{ + State: AuthNotFound, + Detail: fmt.Sprintf("%s#%d is not visible to this token", repo, num), + } + } + n := r.Data.Repository.Node + + s := &Snapshot{ + ViewerLogin: r.Data.Viewer.Login, + Kind: Kind(n.Typename), + Repo: repo, + Num: num, + Title: n.Title, + State: n.State, + URL: n.URL, + Body: n.Body, + Author: actorLogin(n.Author), + CreatedAt: n.CreatedAt, + UpdatedAt: n.UpdatedAt, + } + if s.Kind != KindPullRequest { + return s, nil + } + + s.IsDraft = n.IsDraft + s.Merged = n.Merged + s.BaseRef = n.BaseRefName + s.HeadRef = n.HeadRefName + s.Additions = n.Additions + s.Deletions = n.Deletions + s.ChangedFiles = n.ChangedFiles + s.Mergeable = n.Mergeable + s.MergeStateStatus = n.MergeStateStatus + if n.ReviewDecision != nil { + s.ReviewDecision = *n.ReviewDecision + } + + for _, c := range n.Commits.Nodes { + s.Commits = append(s.Commits, Commit{ + OID: c.Commit.OID, + Abbrev: c.Commit.AbbreviatedOID, + Headline: c.Commit.Headline, + Author: actorName(c.Commit.Author), + When: c.Commit.CommittedDate, + }) + } + s.CommitsTruncated = n.Commits.TotalCount > len(n.Commits.Nodes) + + for _, f := range n.Files.Nodes { + s.Files = append(s.Files, File{ + Path: f.Path, + Additions: f.Additions, + Deletions: f.Deletions, + ChangeType: f.ChangeType, + }) + } + s.FilesTruncated = n.Files.PageInfo.HasNextPage || n.Files.TotalCount > len(n.Files.Nodes) + + for _, rv := range n.Reviews.Nodes { + s.Reviews = append(s.Reviews, Review{ + Author: actorLogin(rv.Author), + State: rv.State, + When: rv.SubmittedAt, + }) + } + + s.Threads.Total = n.ReviewThreads.TotalCount + for _, t := range n.ReviewThreads.Nodes { + if !t.IsResolved { + s.Threads.Unresolved++ + } + } + + if len(n.Rollup.Nodes) > 0 { + if rc := n.Rollup.Nodes[0].Commit.StatusCheckRollup; rc != nil { + s.Checks.State = rc.State + s.Checks.Total = rc.Contexts.TotalCount + for _, ctxNode := range rc.Contexts.Nodes { + switch ctxNode.Typename { + case "CheckRun": + switch { + case ctxNode.Status != "COMPLETED": + s.Checks.Pending++ + case ctxNode.Conclusion == "FAILURE" || + ctxNode.Conclusion == "TIMED_OUT" || + ctxNode.Conclusion == "CANCELLED" || + ctxNode.Conclusion == "STARTUP_FAILURE": + s.Checks.Failing++ + } + case "StatusContext": + switch ctxNode.State { + case "PENDING", "EXPECTED": + s.Checks.Pending++ + case "FAILURE", "ERROR": + s.Checks.Failing++ + } + } + } + } + } + return s, nil +} + +func actorLogin(a *gqlActor) string { + if a == nil { + return "" // a deleted account; the UI renders GHOST rather than blank + } + return a.Login +} + +func actorName(a *gqlActor) string { + if a == nil { + return "" + } + if a.Name != "" { + return a.Name + } + return a.Login +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ghapi/types.go b/nix/pkgs/tools/mention-review/src/internal/ghapi/types.go new file mode 100644 index 000000000..8508c6d87 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ghapi/types.go @@ -0,0 +1,107 @@ +package ghapi + +import "time" + +// Kind is what `issueOrPullRequest`'s `__typename` answered. +// +// 🔴 THE SERVER ANSWERS THIS, NOT US. `mention-open.py` builds `/pull/{id}` for +// every mention and lets github.com redirect, because it structurally cannot +// know whether `#N` is an issue or a PR — and it must not find out, because +// that module carries a test-pinned property that THE CLICK PATH MAKES NO +// NETWORK CALL. Resolution is local and kind-blind; the child asks the server. +type Kind string + +const ( + KindPullRequest Kind = "PullRequest" + KindIssue Kind = "Issue" +) + +// Snapshot is one immutable read of one reference. It is replaced wholesale, +// never mutated (§3.2). +type Snapshot struct { + // 🔴 ViewerLogin is the §10.2 multi-account mitigation and it is NOT + // optional. cli/cli#14370: the OS keyring is not partitioned by account, so + // `gh auth token --secure-storage` can return a token for a DIFFERENT + // account than the config's active one — and this host's hosts.yml carries + // two github.com users. For a read-only tool that is a curiosity; for one + // that can approve and merge it is the difference between approving as + // yourself and approving as somebody else, invisibly. It rides the same + // round trip, so it costs nothing. + ViewerLogin string + + Kind Kind + Repo string + Num int + + Title string + State string + URL string + Body string + Author string + CreatedAt time.Time + UpdatedAt time.Time + + // Pull-request only. + IsDraft bool + Merged bool + BaseRef string + HeadRef string + Additions int + Deletions int + ChangedFiles int + Mergeable string // MERGEABLE | CONFLICTING | UNKNOWN + MergeStateStatus string + ReviewDecision string // "" when the server returned null + Commits []Commit + Files []File + Reviews []Review + Threads ThreadSummary + Checks CheckSummary + + // FilesTruncated is true when the PR has more changed files than one page + // carries. 🔴 REPORTED AS A WORD, never silently dropped — a file list that + // is quietly short is the same class of lie as a green suite that ran + // nothing. + FilesTruncated bool + CommitsTruncated bool +} + +type Commit struct { + OID string + Abbrev string + Headline string + Author string + When time.Time +} + +type File struct { + Path string + Additions int + Deletions int + ChangeType string // ADDED | MODIFIED | REMOVED | RENAMED | COPIED | CHANGED + // Patch is the unified-diff fragment from the REST files endpoint. Empty + // when the API omitted it (binary, or too large) — §6.1 renders that as + // `NO PATCH`, never as an empty diff. + Patch string + PreviousPath string +} + +type Review struct { + Author string + State string // APPROVED | CHANGES_REQUESTED | COMMENTED | DISMISSED | PENDING + When time.Time +} + +type ThreadSummary struct { + Total int + Unresolved int +} + +type CheckSummary struct { + // State is the rollup: SUCCESS | FAILURE | PENDING | ERROR | EXPECTED, or + // "" when there is no rollup at all. + State string + Total int + Failing int + Pending int +} diff --git a/nix/pkgs/tools/mention-review/src/internal/udiff/udiff.go b/nix/pkgs/tools/mention-review/src/internal/udiff/udiff.go new file mode 100644 index 000000000..dfb6d31ec --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/udiff/udiff.go @@ -0,0 +1,344 @@ +// Package udiff turns the REST API's per-file patch fragments into a navigable +// unified diff. +// +// 🔴 PHASE 1 IS API-ONLY. There is no local git here, deliberately: 91% of +// clickable repositories have no local clone (measured, 31 of 341 by their own +// `origin` remotes), so the API path is the MAJORITY path and it is the one +// that has to be polished. Local git is the accelerator for the handful of +// repositories the operator lives in, and it is Phase 3. +// +// 🔴 NO SYNTAX HIGHLIGHTING, AND THAT IS A DECISION RATHER THAN AN OMISSION. +// lazygit — the tool this was asked to resemble — does none either; it renders +// `git diff --color`'s own ANSI. Adding a lexer costs a `recover()` (chroma +// panics on a rule timeout instead of returning an error), a goroutine budget +// (chroma#1377: zero-width-match rule cycles spin a core forever, and the +// per-regex timeout does not help), a custom formatter to keep syntax +// foreground from fighting the diff background, and a supply-chain entry in a +// binary that will eventually hold a token with merge scope. +package udiff + +import ( + "fmt" + "strings" + + "github.com/bluekeyes/go-gitdiff/gitdiff" +) + +// Op is what a line does. Spelled as a small enum rather than a rune so a +// renderer branches on a VALUE and never on a leading character it has to +// re-derive from the text. +type Op int + +const ( + OpContext Op = iota + OpAdd + OpDelete + // OpHunk is the `@@ … @@` header line. It is a LINE in the rendered + // buffer, so hunk navigation can address it directly. + OpHunk + // OpMeta is a line that is neither content nor a hunk header — a rename + // notice, a binary notice, a "no newline" marker. Rendered dim, never + // silently dropped. + OpMeta +) + +// Line is one rendered row of the diff. +type Line struct { + Op Op + Text string // WITHOUT the leading +/-/space; the renderer supplies the marker + // OldNo / NewNo are 0 where the line does not exist on that side. + OldNo, NewNo int + // FileIndex points back at the File this line belongs to, so the Files + // panel and the Diff panel can stay in sync through one shared buffer. + FileIndex int +} + +// Hunk records where a `@@` header landed in the flattened line slice, so +// `]h` / `[h` are an index lookup rather than a scan. +type Hunk struct { + LineIndex int + FileIndex int + Header string +} + +// File is one changed file. +type File struct { + Path string + PrevPath string + ChangeType string + Additions int + Deletions int + IsBinary bool + // NoPatch is true when the API omitted the patch entirely. 🔴 DISTINCT + // FROM an empty diff: an empty diff means "nothing changed in this file", + // NO PATCH means "we were not told what changed". Collapsing them would + // render a binary file as an unchanged one. + NoPatch bool + // FirstLine is the index into Diff.Lines where this file's rows begin. + FirstLine int + LineCount int +} + +// Diff is the whole parsed changeset, flattened into one line slice. +// +// 🔴 ONE FLAT SLICE, NOT A TREE OF FILES. The viewport takes `[]string` and +// slices it for rendering; a tree would have to be flattened on every frame. +// MEASURED: `viewport.View()` with `SoftWrap=false` over a flat slice is +// O(1) in buffer size — 318 µs at 1,000 lines, 347 µs at 4,000 and 342 µs at +// 10,000. +type Diff struct { + Files []File + Lines []Line + Hunks []Hunk + // Truncated is set when the file list was capped at one page. 🔴 CARRIED, + // NOT INFERRED: a short list that looks complete is the same class of lie + // as a green suite that ran nothing. + Truncated bool +} + +// Empty reports whether there is nothing to show at all. +func (d *Diff) Empty() bool { return d == nil || len(d.Lines) == 0 } + +// BuildUnified reconstructs a single unified diff from the API's per-file +// patch fragments. +// +// 🔴 WHY RECONSTRUCT RATHER THAN PARSE THE FRAGMENTS DIRECTLY. The REST +// endpoint returns each file's hunks WITHOUT the `diff --git` / `---` / `+++` +// headers, so a fragment on its own has no filename and no add/delete/rename +// information — those live in sibling JSON fields. Re-attaching them produces +// exactly the input `go-gitdiff` is built for, which is where the fiddly parts +// live: `\ No newline at end of file`, zero-length hunks, and the +// old/new line numbering. +// +// The function is PURE and returns a string, so its output is assertable +// against a literal in a test rather than only through the parser. +func BuildUnified(files []FileInput) string { + var b strings.Builder + for _, f := range files { + if f.Patch == "" { + // A file with no patch contributes no diff text at all. It is + // still carried in the Files list, with NoPatch set, so the panel + // can say NO PATCH — see Parse. + continue + } + old := f.PrevPath + if old == "" { + old = f.Path + } + fmt.Fprintf(&b, "diff --git a/%s b/%s\n", old, f.Path) + switch f.ChangeType { + case "ADDED": + b.WriteString("new file mode 100644\n") + b.WriteString("--- /dev/null\n") + fmt.Fprintf(&b, "+++ b/%s\n", f.Path) + case "REMOVED": + b.WriteString("deleted file mode 100644\n") + fmt.Fprintf(&b, "--- a/%s\n", old) + b.WriteString("+++ /dev/null\n") + case "RENAMED": + fmt.Fprintf(&b, "rename from %s\n", old) + fmt.Fprintf(&b, "rename to %s\n", f.Path) + fmt.Fprintf(&b, "--- a/%s\n", old) + fmt.Fprintf(&b, "+++ b/%s\n", f.Path) + default: + fmt.Fprintf(&b, "--- a/%s\n", old) + fmt.Fprintf(&b, "+++ b/%s\n", f.Path) + } + b.WriteString(f.Patch) + if !strings.HasSuffix(f.Patch, "\n") { + b.WriteString("\n") + } + } + return b.String() +} + +// FileInput is the subset of a changed file BuildUnified and Parse need. It is +// declared here rather than importing the API package so this package stays a +// pure text transformation with no network types in its signature. +type FileInput struct { + Path string + PrevPath string + ChangeType string + Additions int + Deletions int + Patch string +} + +// Parse builds the flattened Diff. +// +// The file LIST comes from `files` (so a file the API gave no patch for still +// appears, marked NoPatch); the CONTENT comes from parsing the reconstructed +// unified diff. 🔴 The two are joined by PATH, and a parsed file whose path +// matches nothing in the list is still appended rather than dropped — a diff +// carrying a file the list did not is a disagreement worth seeing, not one to +// swallow. +func Parse(files []FileInput) (*Diff, error) { + unified := BuildUnified(files) + var parsed []*gitdiff.File + if unified != "" { + var err error + parsed, _, err = gitdiff.Parse(strings.NewReader(unified)) + if err != nil { + return nil, fmt.Errorf("parsing unified diff: %w", err) + } + } + byPath := make(map[string]*gitdiff.File, len(parsed)) + for _, p := range parsed { + byPath[displayPath(p)] = p + } + + d := &Diff{} + for _, f := range files { + fi := File{ + Path: f.Path, + PrevPath: f.PrevPath, + ChangeType: f.ChangeType, + Additions: f.Additions, + Deletions: f.Deletions, + NoPatch: f.Patch == "", + FirstLine: len(d.Lines), + } + idx := len(d.Files) + p := byPath[f.Path] + if p != nil { + fi.IsBinary = p.IsBinary + } + d.appendFileHeader(fi, idx) + if p != nil { + d.appendFragments(p, idx) + } + fi.LineCount = len(d.Lines) - fi.FirstLine + d.Files = append(d.Files, fi) + } + return d, nil +} + +// displayPath is the name a file is known by in the UI: the NEW name, except +// for a deletion where there is no new name. +func displayPath(p *gitdiff.File) string { + if p.NewName != "" { + return p.NewName + } + return p.OldName +} + +func (d *Diff) appendFileHeader(f File, idx int) { + head := f.Path + if f.PrevPath != "" && f.PrevPath != f.Path { + head = f.PrevPath + " -> " + f.Path + } + d.Lines = append(d.Lines, Line{ + Op: OpMeta, + Text: fmt.Sprintf("%s %s +%d -%d", f.ChangeType, head, f.Additions, f.Deletions), + FileIndex: idx, + }) + switch { + case f.NoPatch: + // 🔴 The WORD, not a blank. §6.1: "NO PATCH — binary or too large for + // the API". Phase 1 has no local-git fallback to offer, and saying so + // is better than an empty pane that reads as "no changes". + d.Lines = append(d.Lines, Line{ + Op: OpMeta, + Text: "NO PATCH — binary, or too large for the API", + FileIndex: idx, + }) + case f.IsBinary: + d.Lines = append(d.Lines, Line{ + Op: OpMeta, + Text: "BINARY — no textual diff", + FileIndex: idx, + }) + } +} + +func (d *Diff) appendFragments(p *gitdiff.File, idx int) { + for _, fr := range p.TextFragments { + // ⚠ `fr.Comment` ARRIVES WITHOUT ITS LEADING SPACE — go-gitdiff strips + // the separator when it splits the `@@ … @@ ` line. Joining + // them naively produces `@@ -12,3 +12,6 @@func handle(…` , which is a + // header no `git apply` would accept and which reads as a typo on + // screen. Caught by the round-trip assertion in udiff_test.go. + hdr := fmt.Sprintf("@@ -%d,%d +%d,%d @@", + fr.OldPosition, fr.OldLines, fr.NewPosition, fr.NewLines) + if fr.Comment != "" { + hdr += " " + fr.Comment + } + d.Hunks = append(d.Hunks, Hunk{ + LineIndex: len(d.Lines), + FileIndex: idx, + Header: hdr, + }) + d.Lines = append(d.Lines, Line{Op: OpHunk, Text: hdr, FileIndex: idx}) + + oldNo := int(fr.OldPosition) + newNo := int(fr.NewPosition) + for _, ln := range fr.Lines { + text := strings.TrimSuffix(ln.Line, "\n") + switch ln.Op { + case gitdiff.OpContext: + d.Lines = append(d.Lines, Line{Op: OpContext, Text: text, OldNo: oldNo, NewNo: newNo, FileIndex: idx}) + oldNo++ + newNo++ + case gitdiff.OpAdd: + d.Lines = append(d.Lines, Line{Op: OpAdd, Text: text, NewNo: newNo, FileIndex: idx}) + newNo++ + case gitdiff.OpDelete: + d.Lines = append(d.Lines, Line{Op: OpDelete, Text: text, OldNo: oldNo, FileIndex: idx}) + oldNo++ + } + if ln.NoEOL() { + d.Lines = append(d.Lines, Line{ + Op: OpMeta, + Text: `\ No newline at end of file`, + FileIndex: idx, + }) + } + } + } +} + +// --- navigation ------------------------------------------------------------- +// +// All pure. §5.1 drives these from a table with literal expected values. + +// NextHunk returns the line index of the first hunk header strictly after +// `from`, or -1 when there is none. 🔴 It crosses FILE boundaries on purpose: +// `]h` is "the next hunk in this review", not "in this file". +func (d *Diff) NextHunk(from int) int { + for _, h := range d.Hunks { + if h.LineIndex > from { + return h.LineIndex + } + } + return -1 +} + +// PrevHunk returns the line index of the last hunk header strictly before +// `from`, or -1 when there is none. +func (d *Diff) PrevHunk(from int) int { + best := -1 + for _, h := range d.Hunks { + if h.LineIndex < from { + best = h.LineIndex + } else { + break + } + } + return best +} + +// FileStart returns the line index where file `i` begins, or -1. +func (d *Diff) FileStart(i int) int { + if i < 0 || i >= len(d.Files) { + return -1 + } + return d.Files[i].FirstLine +} + +// FileAt returns the index of the file owning line `i`, or -1. +func (d *Diff) FileAt(i int) int { + if i < 0 || i >= len(d.Lines) { + return -1 + } + return d.Lines[i].FileIndex +} diff --git a/nix/pkgs/tools/mention-review/src/internal/udiff/udiff_test.go b/nix/pkgs/tools/mention-review/src/internal/udiff/udiff_test.go new file mode 100644 index 000000000..5e6ae8936 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/udiff/udiff_test.go @@ -0,0 +1,307 @@ +package udiff + +import ( + "strings" + "testing" +) + +// 🔴 EVERY FIXTURE HERE IS SYNTHETIC. This repository is PUBLIC, and captured +// text — anyone's diffs, filenames, message bodies — must not land in it in any +// form, fixtures included. A test needs the SHAPE; these are regenerated. +// +// 🔴 EXPECTED VALUES ARE WRITTEN BY HAND FROM THE UNIFIED-DIFF FORMAT, NEVER +// READ OFF THE IMPLEMENTATION. Where a count is asserted it is a number counted +// from the fixture text above it. + +const patchModified = `@@ -12,3 +12,6 @@ func handle(req *Request) error { + ctx := build(req) +- return ctx.run() ++ if ctx.stale() { ++ ctx.refresh() ++ } ++ return ctx.run() + log.Debug("done") +` + +const patchAdded = `@@ -0,0 +1,3 @@ ++package widget ++ ++const Name = "widget" +` + +const patchRemoved = `@@ -1,2 +0,0 @@ +-package legacy +- +` + +const patchNoNewline = `@@ -1,2 +1,2 @@ + first +-second +\ No newline at end of file ++second line +\ No newline at end of file +` + +const patchTwoHunks = `@@ -3,2 +3,3 @@ type A struct { + x int ++ y int + z int +@@ -40,2 +41,2 @@ func (a A) Sum() int { +- return a.x ++ return a.x + a.y + } +` + +func modified() FileInput { + return FileInput{Path: "pkg/handler.go", ChangeType: "MODIFIED", Additions: 4, Deletions: 1, Patch: patchModified} +} + +// --- BuildUnified: assert the TEXT, not just that parsing succeeds ----------- + +func TestBuildUnifiedReattachesTheHeadersTheRESTAPIOmits(t *testing.T) { + got := BuildUnified([]FileInput{modified()}) + want := "diff --git a/pkg/handler.go b/pkg/handler.go\n" + + "--- a/pkg/handler.go\n" + + "+++ b/pkg/handler.go\n" + + patchModified + if got != want { + t.Errorf("BuildUnified mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestBuildUnifiedUsesDevNullForAddedAndRemoved(t *testing.T) { + add := BuildUnified([]FileInput{{Path: "pkg/widget.go", ChangeType: "ADDED", Additions: 3, Patch: patchAdded}}) + if !strings.Contains(add, "--- /dev/null\n+++ b/pkg/widget.go\n") { + t.Errorf("ADDED header wrong:\n%s", add) + } + del := BuildUnified([]FileInput{{Path: "pkg/legacy.go", ChangeType: "REMOVED", Deletions: 2, Patch: patchRemoved}}) + if !strings.Contains(del, "--- a/pkg/legacy.go\n+++ /dev/null\n") { + t.Errorf("REMOVED header wrong:\n%s", del) + } +} + +func TestBuildUnifiedCarriesTheOldPathForARename(t *testing.T) { + got := BuildUnified([]FileInput{{ + Path: "pkg/new_name.go", PrevPath: "pkg/old_name.go", + ChangeType: "RENAMED", Patch: patchModified, + }}) + for _, want := range []string{ + "diff --git a/pkg/old_name.go b/pkg/new_name.go\n", + "rename from pkg/old_name.go\n", + "rename to pkg/new_name.go\n", + } { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} + +// 🔴 A FILE WITH NO PATCH CONTRIBUTES NO DIFF TEXT — but it must still APPEAR +// in the file list. The two halves are asserted together because either alone +// is satisfied by the wrong implementation. +func TestAFileWithNoPatchContributesNoTextButStillAppears(t *testing.T) { + in := []FileInput{ + {Path: "assets/logo.png", ChangeType: "MODIFIED"}, // no Patch + modified(), + } + if txt := BuildUnified(in); strings.Contains(txt, "logo.png") { + t.Errorf("a patchless file leaked into the diff text:\n%s", txt) + } + d, err := Parse(in) + if err != nil { + t.Fatal(err) + } + if len(d.Files) != 2 { + t.Fatalf("Files = %d, want 2", len(d.Files)) + } + if !d.Files[0].NoPatch { + t.Error("the patchless file is not marked NoPatch") + } + if d.Files[1].NoPatch { + t.Error("the file WITH a patch is marked NoPatch") + } + // The WORD, not a blank — §6.1. + if !strings.Contains(renderedText(d), "NO PATCH") { + t.Error("the NO PATCH word is missing from the rendered lines") + } +} + +func TestAnEmptyChangesetParsesToAnEmptyDiff(t *testing.T) { + d, err := Parse(nil) + if err != nil { + t.Fatal(err) + } + if !d.Empty() { + t.Errorf("Empty() = false over %d lines", len(d.Lines)) + } + if len(d.Hunks) != 0 { + t.Errorf("Hunks = %d, want 0", len(d.Hunks)) + } +} + +// --- Parse: line ops and numbering ------------------------------------------ + +func TestParseAssignsOpsAndLineNumbersFromTheHunkHeader(t *testing.T) { + d, err := Parse([]FileInput{modified()}) + if err != nil { + t.Fatal(err) + } + // Expected rows, counted by hand off `patchModified`. The file header and + // the `@@` line come first; then the hunk body in order. + type want struct { + op Op + text string + oldNo int + newNo int + } + body := []want{ + {OpContext, "\tctx := build(req)", 12, 12}, + {OpDelete, "\treturn ctx.run()", 13, 0}, + {OpAdd, "\tif ctx.stale() {", 0, 13}, + {OpAdd, "\t\tctx.refresh()", 0, 14}, + {OpAdd, "\t}", 0, 15}, + {OpAdd, "\treturn ctx.run()", 0, 16}, + {OpContext, "\tlog.Debug(\"done\")", 14, 17}, + } + // Row 0 is the file header (OpMeta); row 1 is the `@@` header (OpHunk). + if d.Lines[0].Op != OpMeta { + t.Errorf("line 0 Op = %v, want OpMeta", d.Lines[0].Op) + } + if d.Lines[1].Op != OpHunk { + t.Fatalf("line 1 Op = %v, want OpHunk", d.Lines[1].Op) + } + if got, want := d.Lines[1].Text, "@@ -12,3 +12,6 @@ func handle(req *Request) error {"; got != want { + t.Errorf("hunk header = %q, want %q", got, want) + } + for i, w := range body { + got := d.Lines[2+i] + if got.Op != w.op || got.Text != w.text || got.OldNo != w.oldNo || got.NewNo != w.newNo { + t.Errorf("line %d = {op:%v text:%q old:%d new:%d}, want {op:%v text:%q old:%d new:%d}", + 2+i, got.Op, got.Text, got.OldNo, got.NewNo, w.op, w.text, w.oldNo, w.newNo) + } + } +} + +func TestParsePreservesTheNoNewlineMarker(t *testing.T) { + d, err := Parse([]FileInput{{Path: "notes.txt", ChangeType: "MODIFIED", Patch: patchNoNewline}}) + if err != nil { + t.Fatal(err) + } + n := strings.Count(renderedText(d), `\ No newline at end of file`) + // TWO markers in the fixture — one on the deleted line, one on the added + // line. Asserting "at least one" would pass with the second dropped. + if n != 2 { + t.Errorf("no-newline markers = %d, want 2\n%s", n, renderedText(d)) + } +} + +// --- hunk navigation -------------------------------------------------------- + +func twoFileDiff(t *testing.T) *Diff { + t.Helper() + d, err := Parse([]FileInput{ + {Path: "pkg/a.go", ChangeType: "MODIFIED", Patch: patchTwoHunks}, + {Path: "pkg/b.go", ChangeType: "ADDED", Patch: patchAdded}, + }) + if err != nil { + t.Fatal(err) + } + return d +} + +func TestHunkIndexesPointAtHunkHeaderLines(t *testing.T) { + d := twoFileDiff(t) + // THREE hunks, counted from the fixtures: two in a.go, one in b.go. + if len(d.Hunks) != 3 { + t.Fatalf("Hunks = %d, want 3", len(d.Hunks)) + } + for i, h := range d.Hunks { + if d.Lines[h.LineIndex].Op != OpHunk { + t.Errorf("hunk %d points at line %d, whose Op is %v", i, h.LineIndex, d.Lines[h.LineIndex].Op) + } + } +} + +// 🔴 `]h` CROSSES FILE BOUNDARIES ON PURPOSE — it is "the next hunk in this +// review", not "in this file". The third hunk lives in a different file from +// the first two, and reaching it is the assertion. +func TestNextHunkCrossesFileBoundaries(t *testing.T) { + d := twoFileDiff(t) + h0, h1, h2 := d.Hunks[0].LineIndex, d.Hunks[1].LineIndex, d.Hunks[2].LineIndex + if d.Hunks[0].FileIndex == d.Hunks[2].FileIndex { + t.Fatal("fixture is wrong: the first and last hunks must be in DIFFERENT files") + } + if got := d.NextHunk(0); got != h0 { + t.Errorf("NextHunk(0) = %d, want %d", got, h0) + } + if got := d.NextHunk(h0); got != h1 { + t.Errorf("NextHunk(%d) = %d, want %d", h0, got, h1) + } + if got := d.NextHunk(h1); got != h2 { + t.Errorf("NextHunk(%d) = %d, want %d (a DIFFERENT file)", h1, got, h2) + } + if got := d.NextHunk(h2); got != -1 { + t.Errorf("NextHunk(last) = %d, want -1", got) + } +} + +func TestPrevHunkIsTheMirrorImage(t *testing.T) { + d := twoFileDiff(t) + h0, h1, h2 := d.Hunks[0].LineIndex, d.Hunks[1].LineIndex, d.Hunks[2].LineIndex + if got := d.PrevHunk(h2); got != h1 { + t.Errorf("PrevHunk(%d) = %d, want %d", h2, got, h1) + } + if got := d.PrevHunk(h1); got != h0 { + t.Errorf("PrevHunk(%d) = %d, want %d", h1, got, h0) + } + if got := d.PrevHunk(h0); got != -1 { + t.Errorf("PrevHunk(first) = %d, want -1", got) + } +} + +func TestSingleHunkFileHasNoNeighbours(t *testing.T) { + d, err := Parse([]FileInput{{Path: "pkg/only.go", ChangeType: "ADDED", Patch: patchAdded}}) + if err != nil { + t.Fatal(err) + } + h := d.Hunks[0].LineIndex + if got := d.NextHunk(h); got != -1 { + t.Errorf("NextHunk = %d, want -1", got) + } + if got := d.PrevHunk(h); got != -1 { + t.Errorf("PrevHunk = %d, want -1", got) + } +} + +func TestFileStartAndFileAtAreInverses(t *testing.T) { + d := twoFileDiff(t) + for i := range d.Files { + start := d.FileStart(i) + if start < 0 { + t.Fatalf("FileStart(%d) = %d", i, start) + } + if got := d.FileAt(start); got != i { + t.Errorf("FileAt(FileStart(%d)) = %d", i, got) + } + } + // Out of range in both directions returns -1, not a clamped index — a + // clamp would make an off-by-one silently address the wrong file. + if got := d.FileStart(-1); got != -1 { + t.Errorf("FileStart(-1) = %d, want -1", got) + } + if got := d.FileStart(len(d.Files)); got != -1 { + t.Errorf("FileStart(past end) = %d, want -1", got) + } + if got := d.FileAt(len(d.Lines)); got != -1 { + t.Errorf("FileAt(past end) = %d, want -1", got) + } +} + +func renderedText(d *Diff) string { + var b strings.Builder + for _, l := range d.Lines { + b.WriteString(l.Text) + b.WriteString("\n") + } + return b.String() +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/app.go b/nix/pkgs/tools/mention-review/src/internal/ui/app.go new file mode 100644 index 000000000..62644586f --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/app.go @@ -0,0 +1,455 @@ +package ui + +import ( + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + + "github.com/innovation-upstream/devrc/mention-review/internal/ghapi" + "github.com/innovation-upstream/devrc/mention-review/internal/udiff" +) + +// Panel identifies a focusable pane. +type Panel int + +const ( + PanelOverview Panel = iota + PanelCommits + PanelFiles + PanelDiff + panelCount +) + +// Title is the WORD in the panel's border, numbered so `tab` has a visible +// order rather than a remembered one. +func (p Panel) Title() string { + switch p { + case PanelOverview: + return "1 Overview" + case PanelCommits: + return "2 Commits" + case PanelFiles: + return "3 Files" + } + return "4 Diff" +} + +// --- messages --------------------------------------------------------------- + +// PRLoaded carries the one GraphQL read's result. +type PRLoaded struct { + Snap *ghapi.Snapshot + Err error +} + +// DiffLoaded carries the REST diff's result. +type DiffLoaded struct { + Diff *udiff.Diff + Err error +} + +// --- the model -------------------------------------------------------------- + +// App is the root model. It owns everything more than one panel reads (§3.2). +type App struct { + Owner string + Name string + Num int + + Load LoadState + Snap *ghapi.Snapshot + Diff *udiff.Diff + Err error + Focus Panel + + Width, Height int + + // Per-panel cursors. A sub-model owns state only IT reads. + commitCur int + fileCur int + diffCur int + + vp viewport.Model + body viewport.Model // the issue card / error card body + help help.Model + showFull bool + + // runner is the effect surface. 🔴 NOT A PACKAGE-LEVEL GLOBAL: a global + // would make `Update` depend on process state, and a test that forgot to + // set it would silently exercise the LIVE network. Nil is safe — `Step` is + // pure and never touches it, so every pure test constructs an App without + // one and cannot reach the network even by mistake. + runner Runner + + // Quitting is set by the pure Step; the impure Update turns it into + // tea.Quit. 🔴 Step never returns a tea.Cmd, not even that one. + Quitting bool +} + +// Repo renders "owner/repo". +func (a App) Repo() string { return a.Owner + "/" + a.Name } + +// New builds a loading App. +func New(owner, name string, num int) App { + h := help.New() + h.ShowAll = false + a := App{ + Owner: owner, + Name: name, + Num: num, + Load: LoadLoading, + Focus: PanelDiff, // ⚠ see FocusDefault + Width: 100, + Height: 30, + vp: viewport.New(), + body: viewport.New(), + help: h, + } + // 🔴 SoftWrap = false IS A PERFORMANCE DECISION, MEASURED, NOT A STYLE ONE. + // With soft wrap ON, `viewport.calculateLine` loops EVERY line calling + // `ansi.StringWidth`, and `View()`, `TotalLineCount()` and `maxYOffset()` + // each trigger their own full pass. MEASURED on this host at width 92: + // + // lines SoftWrap=false SoftWrap=true + // 1000 318 us 2,450 us + // 4000 347 us 8,457 us + // 10000 342 us 21,554 us + // + // i.e. false is FLAT in buffer size and true is linear — 63x slower at + // 10,000 lines, and 21.5 ms per View() blows a 60 fps frame budget on its + // own. Horizontal scrolling is the trade, and it is the right one. + a.vp.SoftWrap = false + a.body.SoftWrap = true // a prose body is short; wrapping is what it wants + return a +} + +// FocusDefault is the panel the cursor starts in. +// +// ⚠ THE DIFF, NOT THE FILES PANEL. MEASURED over 300 recent PRs: the median PR +// in this repo touches ONE file, and four in a second repository of the +// operator's. For a large fraction of openings the Files panel has nothing to +// choose, and landing there wastes a keystroke on every single review. The +// panels still exist for the p90 (6 files and 19 respectively); they just +// should not be where the cursor starts. +const FocusDefault = PanelDiff + +// --- the pure step ---------------------------------------------------------- + +// Step is the PURE half of Update. It returns the next App and the intents it +// wants performed. It performs no I/O and constructs no closure that does. +// +// 🔴 EVERYTHING INTERESTING HAPPENS HERE, AND 90% OF THE TESTS DRIVE THIS. +func (a App) Step(msg tea.Msg) (App, []Intent) { + switch m := msg.(type) { + + case tea.WindowSizeMsg: + a.Width, a.Height = m.Width, m.Height + a.relayout() + return a, nil + + case PRLoaded: + if m.Err != nil { + a.Load = LoadFailed + a.Err = m.Err + a.setBody(errorCardBody(a.Repo(), a.Num, m.Err)) + return a, nil + } + a.Load = LoadReady + a.Snap = m.Snap + a.clampCursors() + if m.Snap.Kind == ghapi.KindIssue { + // 🔴 THE ISSUE CARD IS TERMINAL. No comment posting, no labels, no + // close/reopen, no navigation to related issues. §4 draws that line + // explicitly, and the card exists only because `mention-open.py` + // structurally cannot know whether `#N` is an issue or a PR — it + // builds `/pull/{id}` for everything and lets github.com redirect. + // There is nothing to fetch a diff for. + a.setBody(m.Snap.Body) + return a, nil + } + a.relayout() + // The three metadata panels are usable NOW; the diff is a second read + // because GraphQL cannot return patch text. + return a, []Intent{FetchDiff{Owner: a.Owner, Name: a.Name, Num: a.Num}} + + case DiffLoaded: + if m.Err != nil { + // 🔴 A DIFF FAILURE IS NOT A PAGE FAILURE. The metadata panels are + // already on screen and still true; replacing them with an error + // card would throw away a working screen. The Diff panel says so + // and everything else keeps working. + a.Diff = nil + a.Err = m.Err + return a, nil + } + a.Diff = m.Diff + a.diffCur = 0 + a.rebuildDiffContent() + a.syncDiffViewport() + return a, nil + + case tea.KeyPressMsg: + return a.stepKey(m) + } + return a, nil +} + +// stepKey walks the ONE dispatch table. +func (a App) stepKey(k tea.KeyPressMsg) (App, []Intent) { + for _, b := range Dispatch() { + if !key.Matches(k, b.Binding) { + continue + } + return a.act(b.Action) + } + return a, nil +} + +// act is the handler body for one action. Pure. +func (a App) act(act Action) (App, []Intent) { + switch act { + case ActQuit: + a.Quitting = true + return a, nil + + case ActFullHelp: + a.showFull = !a.showFull + a.help.ShowAll = a.showFull + a.relayout() + return a, nil + + case ActBrowser: + // 🔴 A ZERO-NETWORK-INTENT PATH IS NOT WHAT THIS IS. It emits exactly + // one intent and the test asserts the value, so the assertion has its + // own positive control built in. + url := a.browserURL() + if url == "" { + return a, nil + } + return a, []Intent{OpenBrowser{URL: url}} + + case ActRetry: + if a.Load != LoadFailed { + // ⚠ `r` IS INERT ON A HEALTHY SCREEN, DELIBERATELY. Re-fetching + // under the operator would move the cursor out from under them. + return a, nil + } + a.Load = LoadLoading + a.Err = nil + return a, []Intent{FetchPR{Owner: a.Owner, Name: a.Name, Num: a.Num}} + + case ActNextPanel: + a.Focus = a.nextFocusable(+1) + a.relayout() + return a, nil + case ActPrevPanel: + a.Focus = a.nextFocusable(-1) + a.relayout() + return a, nil + } + + // Everything below moves a cursor inside the focused panel. + return a.move(act), nil +} + +// nextFocusable cycles focus, skipping panels that do not exist in the current +// state. 🔴 An issue has no commits, files or diff, so `tab` on an issue card +// must not park the cursor on three empty boxes — it stays on Overview, which +// is the only panel an issue HAS. +func (a App) nextFocusable(dir int) Panel { + if !a.isPullRequest() { + return PanelOverview + } + n := int(panelCount) + return Panel(((int(a.Focus)+dir)%n + n) % n) +} + +func (a App) isPullRequest() bool { + return a.Snap != nil && a.Snap.Kind == ghapi.KindPullRequest +} + +// move applies a cursor action to whichever panel has focus. +func (a *App) moveIn(act Action, cur *int, n int) { + if n == 0 { + return + } + page := max(1, a.panelBodyHeight()/2) + switch act { + case ActUp: + *cur-- + case ActDown: + *cur++ + case ActPageUp: + *cur -= page + case ActPageDown: + *cur += page + case ActTop: + *cur = 0 + case ActBottom: + *cur = n - 1 + } + *cur = clamp(*cur, 0, n-1) +} + +func (a App) move(act Action) App { + switch a.Focus { + case PanelCommits: + if a.Snap != nil { + a.moveIn(act, &a.commitCur, len(a.Snap.Commits)) + } + case PanelFiles: + if a.Snap != nil { + n := len(a.Snap.Files) + before := a.fileCur + a.moveIn(act, &a.fileCur, n) + if a.fileCur != before && a.Diff != nil { + // 🔴 CROSS-PANEL EFFECTS GO THROUGH THE ROOT, NOT THROUGH A + // POINTER BETWEEN SUB-MODELS (§3.2). Selecting a file moves the + // diff cursor; the Files panel does not hold the Diff panel. + if start := a.Diff.FileStart(a.fileCur); start >= 0 { + a.diffCur = start + a.syncDiffViewport() + } + } + } + case PanelDiff: + if a.Diff != nil { + switch act { + case ActNextHunk: + if i := a.Diff.NextHunk(a.diffCur); i >= 0 { + a.diffCur = i + } + case ActPrevHunk: + if i := a.Diff.PrevHunk(a.diffCur); i >= 0 { + a.diffCur = i + } + case ActNextFile: + if f := a.Diff.FileAt(a.diffCur); f >= 0 && f+1 < len(a.Diff.Files) { + a.diffCur = a.Diff.FileStart(f + 1) + } + case ActPrevFile: + if f := a.Diff.FileAt(a.diffCur); f > 0 { + a.diffCur = a.Diff.FileStart(f - 1) + } + default: + a.moveIn(act, &a.diffCur, len(a.Diff.Lines)) + } + a.syncFileCursorFromDiff() + a.syncDiffViewport() + } + case PanelOverview: + // The body viewport (an issue body, or an error card) scrolls. + switch act { + case ActUp: + a.body.ScrollUp(1) + case ActDown: + a.body.ScrollDown(1) + case ActPageUp: + a.body.HalfPageUp() + case ActPageDown: + a.body.HalfPageDown() + case ActTop: + a.body.GotoTop() + case ActBottom: + a.body.GotoBottom() + } + } + return a +} + +// syncFileCursorFromDiff keeps the Files panel's highlight on whatever file the +// diff cursor is inside — the other half of the one-way cross-panel link. +func (a *App) syncFileCursorFromDiff() { + if a.Diff == nil { + return + } + if f := a.Diff.FileAt(a.diffCur); f >= 0 { + a.fileCur = f + } +} + +func (a *App) clampCursors() { + if a.Snap == nil { + return + } + a.commitCur = clamp(a.commitCur, 0, max(0, len(a.Snap.Commits)-1)) + a.fileCur = clamp(a.fileCur, 0, max(0, len(a.Snap.Files)-1)) +} + +func (a App) browserURL() string { + if a.Snap != nil && a.Snap.URL != "" { + return a.Snap.URL + } + // 🔴 A URL EVEN WHEN THE FETCH FAILED. §6.1: a 404 card offers `o` because + // a browser session may have access this token does not, and an OFFLINE + // card offers it because the browser may reach what we could not. Built + // from argv, which is always present, rather than from a snapshot that may + // not be. + return "https://github.com/" + a.Repo() + "/pull/" + itoa(a.Num) +} + +// --- the impure half -------------------------------------------------------- + +// Init fires the one GraphQL read. +// +// ✅ `Init() tea.Cmd` IS UNCHANGED IN v2. It churned during the beta and +// reverted; several secondary sources still say otherwise and they are wrong. +func (a App) Init() tea.Cmd { + if a.runner == nil { + return nil + } + return Run(FetchPR{Owner: a.Owner, Name: a.Name, Num: a.Num}, a.runner) +} + +// SetRunner installs the effect surface. Called once, at startup, by main — +// and by the one end-to-end test, with a fake. +func (a *App) SetRunner(r Runner) { a.runner = r } + +// Update is the thin impure shell over Step. 🔴 IT CONTAINS NO LOGIC — every +// branch lives in Step, where a test can see it. +func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + next, intents := a.Step(msg) + if next.Quitting { + return next, tea.Quit + } + if next.runner == nil { + return next, nil + } + return next, tea.Batch(RunAll(intents, next.runner)...) +} + +func clamp(v, lo, hi int) int { + if hi < lo { + return lo + } + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/app_test.go b/nix/pkgs/tools/mention-review/src/internal/ui/app_test.go new file mode 100644 index 000000000..477dc0dad --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/app_test.go @@ -0,0 +1,533 @@ +package ui + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/innovation-upstream/devrc/mention-review/internal/ghapi" + "github.com/innovation-upstream/devrc/mention-review/internal/udiff" +) + +// 🔴 LAYER 2 — `Step()` DRIVEN OVER MESSAGE SEQUENCES. +// +// Because the command seam made `Step` PURE and made intents DATA, these assert +// on STATE and INTENTS, never on a rendered string. There are no golden frames +// anywhere in this suite: `teatest`'s `Output()` is a byte STREAM of every frame +// the renderer emitted — a transcript of REDRAWS, not a screen — so a golden +// file is coupled to render scheduling, which is not behaviour. +// +// 🔴 EVERY FIXTURE IS SYNTHETIC. This repository is public. +// +// ⚠ THE FIXTURE VALUES ARE PAIRWISE DISTINCT AND DISTINCT FROM EVERY CONSTANT +// THE ASSERTIONS NAME — PR 1559, owner `gardenersguild`, name `trowelcast`, +// 3 commits, 2 files, 7 checks. A fixture that can only ever produce a +// constant's own value cannot see a mutant that hardcodes the literal. + +const ( + fxOwner = "gardenersguild" + fxName = "trowelcast" + fxNum = 1559 + fxRepo = fxOwner + "/" + fxName +) + +func fixturePR() *ghapi.Snapshot { + return &ghapi.Snapshot{ + ViewerLogin: "a-reviewer", + Kind: ghapi.KindPullRequest, + Repo: fxRepo, + Num: fxNum, + Title: "Refresh the stale context before running", + State: "OPEN", + URL: "https://github.com/" + fxRepo + "/pull/1559", + Author: "an-author", + CreatedAt: time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 9, 12, 17, 30, 0, 0, time.UTC), + BaseRef: "main", + HeadRef: "fix/stale-context", + Additions: 312, + Deletions: 40, + ChangedFiles: 2, + Mergeable: "MERGEABLE", + MergeStateStatus: "BLOCKED", + ReviewDecision: "CHANGES_REQUESTED", + Commits: []ghapi.Commit{ + {OID: "a1b2c3d4", Abbrev: "a1b2c3d", Headline: "refresh the stale context", Author: "an-author"}, + {OID: "e4f5a6b7", Abbrev: "e4f5a6b", Headline: "address review", Author: "an-author"}, + {OID: "c8d9e0f1", Abbrev: "c8d9e0f", Headline: "add the regression test", Author: "an-author"}, + }, + Files: []ghapi.File{ + {Path: "pkg/handler.go", Additions: 9, Deletions: 1, ChangeType: "MODIFIED"}, + {Path: "pkg/widget.go", Additions: 3, Deletions: 0, ChangeType: "ADDED"}, + }, + Reviews: []ghapi.Review{{Author: "a-reviewer", State: "CHANGES_REQUESTED"}}, + Threads: ghapi.ThreadSummary{Total: 7, Unresolved: 3}, + Checks: ghapi.CheckSummary{State: "FAILURE", Total: 7, Failing: 3}, + } +} + +func fixtureIssue() *ghapi.Snapshot { + return &ghapi.Snapshot{ + ViewerLogin: "a-reviewer", + Kind: ghapi.KindIssue, + Repo: fxRepo, + Num: 1656, + Title: "Widget refresh drops the stale context", + State: "OPEN", + URL: "https://github.com/" + fxRepo + "/issues/1656", + Author: "an-author", + Body: "The widget keeps a context past its refresh window.\n\nSteps:\n1. build\n2. wait\n3. observe", + } +} + +func fixtureDiff(t *testing.T) *udiff.Diff { + t.Helper() + d, err := udiff.Parse([]udiff.FileInput{ + {Path: "pkg/handler.go", ChangeType: "MODIFIED", Additions: 9, Deletions: 1, + Patch: "@@ -12,1 +12,2 @@ func handle(req *Request) error {\n ctx := build(req)\n+\tctx.refresh()\n"}, + {Path: "pkg/widget.go", ChangeType: "ADDED", Additions: 3, + Patch: "@@ -0,0 +1,3 @@\n+package widget\n+\n+const Name = \"widget\"\n"}, + }) + if err != nil { + t.Fatal(err) + } + return d +} + +func ready(t *testing.T) App { + t.Helper() + a := New(fxOwner, fxName, fxNum) + a.Width, a.Height = 140, 40 + a, _ = a.Step(PRLoaded{Snap: fixturePR()}) + a, _ = a.Step(DiffLoaded{Diff: fixtureDiff(t)}) + return a +} + +// --- the fetch sequence ------------------------------------------------------ + +// 🔴 EXACTLY ONE GRAPHQL READ, AND THE DIFF IS THE SECOND — because GraphQL +// cannot return patch text. The assertion is on the VALUE of the intent, not +// on its count, so a mutant that emitted the right number of the wrong thing +// does not survive. +func TestLoadingAPullRequestAsksForTheDiffAndNothingElse(t *testing.T) { + a := New(fxOwner, fxName, fxNum) + if a.Load != LoadLoading { + t.Fatalf("a fresh App is in state %v", a.Load) + } + next, intents := a.Step(PRLoaded{Snap: fixturePR()}) + if next.Load != LoadReady { + t.Errorf("Load = %v, want LoadReady", next.Load) + } + want := []Intent{FetchDiff{Owner: fxOwner, Name: fxName, Num: fxNum}} + if !intentsEqual(intents, want) { + t.Errorf("intents = %v, want %v", intents, want) + } +} + +// 🔴 AN ISSUE ASKS FOR NO DIFF AT ALL. §4: the card is read-only and terminal, +// and there is nothing to review — so a FetchDiff here would be a wasted round +// trip against an endpoint that would 404. +// +// The pair is what makes this evidence: the SAME code path produces a non-empty +// intent list for a pull request (the test above), so "empty" here is a claim +// about the branch rather than about a `Step` wired to nothing. +func TestLoadingAnIssueEmitsZeroIntents(t *testing.T) { + a := New(fxOwner, fxName, 1656) + next, intents := a.Step(PRLoaded{Snap: fixtureIssue()}) + if len(intents) != 0 { + t.Errorf("an issue emitted %v, want none", intents) + } + if next.Snap.Kind != ghapi.KindIssue { + t.Errorf("Kind = %v", next.Snap.Kind) + } + if next.Load != LoadReady { + t.Errorf("Load = %v, want LoadReady — an issue is a successful load", next.Load) + } + // The positive control, in the same test. + _, prIntents := New(fxOwner, fxName, fxNum).Step(PRLoaded{Snap: fixturePR()}) + if len(prIntents) == 0 { + t.Fatal("the PULL REQUEST path also emitted nothing — the zero above " + + "is a claim about Step being wired to nothing, not about issues") + } +} + +// 🔴 A DIFF FAILURE IS NOT A PAGE FAILURE. The metadata panels are already on +// screen and still true; replacing them with an error card would throw away a +// working screen. +func TestADiffFailureLeavesTheMetadataPanelsIntact(t *testing.T) { + a := New(fxOwner, fxName, fxNum) + a, _ = a.Step(PRLoaded{Snap: fixturePR()}) + next, intents := a.Step(DiffLoaded{Err: &ghapi.APIError{State: ghapi.AuthOther, Detail: "connection reset"}}) + + if next.Load != LoadReady { + t.Errorf("Load = %v, want LoadReady — the PR itself loaded fine", next.Load) + } + if next.Snap == nil { + t.Fatal("the snapshot was discarded by a DIFF failure") + } + if next.Diff != nil { + t.Error("Diff is non-nil after a failure") + } + if len(intents) != 0 { + t.Errorf("a diff failure emitted %v — it must not retry by itself", intents) + } + // And the failure is VISIBLE, not swallowed. + if !strings.Contains(stripANSI(next.render()), "connection reset") { + t.Error("the diff error is not on screen anywhere") + } +} + +func TestAFetchFailureRendersACardAndKeepsTheWindowOpen(t *testing.T) { + for _, st := range []ghapi.AuthState{ + ghapi.AuthNoToken, ghapi.AuthRejected, ghapi.AuthNotFound, ghapi.AuthRateLimited, + } { + a := New(fxOwner, fxName, fxNum) + a.Width, a.Height = 140, 40 + next, intents := a.Step(PRLoaded{Err: &ghapi.APIError{State: st, Detail: "detail text"}}) + if next.Load != LoadFailed { + t.Errorf("%v: Load = %v", st, next.Load) + } + if len(intents) != 0 { + t.Errorf("%v: emitted %v — a failure must not retry by itself", st, intents) + } + if next.Quitting { + t.Errorf("%v: the app QUIT on a failure — a window that flashes and "+ + "vanishes teaches the operator nothing", st) + } + // 🔴 THE STATE'S OWN WORD IS ON SCREEN, so NO TOKEN and TOKEN REJECTED + // are distinguishable by reading, not by exit code. + screen := stripANSI(next.render()) + if !strings.Contains(screen, st.Word()) { + t.Errorf("%v: the card does not carry the word %q\n%s", st, st.Word(), screen) + } + } +} + +// --- keys -------------------------------------------------------------------- + +func TestQuitSetsQuittingAndEmitsNoIntents(t *testing.T) { + for _, k := range []string{"q", "esc", "ctrl+c"} { + next, intents := ready(t).Step(keyPress(k)) + if !next.Quitting { + t.Errorf("%q did not set Quitting", k) + } + if len(intents) != 0 { + t.Errorf("%q emitted %v", k, intents) + } + } +} + +// 🔴 `o` EMITS EXACTLY ONE OpenBrowser WITH THE SNAPSHOT'S OWN URL. Asserting +// the VALUE rather than the count is what makes this a real test: a mutant +// that opened the repository root instead would produce the right count. +func TestOpenBrowserUsesTheSnapshotURL(t *testing.T) { + _, intents := ready(t).Step(keyPress("o")) + want := []Intent{OpenBrowser{URL: "https://github.com/" + fxRepo + "/pull/1559"}} + if !intentsEqual(intents, want) { + t.Errorf("intents = %v, want %v", intents, want) + } +} + +// 🔴 AND `o` WORKS WITH NO SNAPSHOT AT ALL. §6.1: a 404 card offers `o` because +// a browser session may have access this token does not, and an OFFLINE card +// offers it because the browser may reach what we could not. Built from argv, +// which is always present. +func TestOpenBrowserWorksOnAFailureCard(t *testing.T) { + a := New(fxOwner, fxName, fxNum) + a, _ = a.Step(PRLoaded{Err: &ghapi.APIError{State: ghapi.AuthNotFound}}) + _, intents := a.Step(keyPress("o")) + want := []Intent{OpenBrowser{URL: "https://github.com/" + fxRepo + "/pull/1559"}} + if !intentsEqual(intents, want) { + t.Errorf("intents = %v, want %v", intents, want) + } +} + +// `r` re-fetches ONLY from a failed state, and it is inert otherwise — +// re-fetching under the operator would move the cursor out from under them. +func TestRetryOnlyFiresFromAFailedState(t *testing.T) { + a := New(fxOwner, fxName, fxNum) + a, _ = a.Step(PRLoaded{Err: &ghapi.APIError{State: ghapi.AuthRateLimited}}) + next, intents := a.Step(keyPress("r")) + want := []Intent{FetchPR{Owner: fxOwner, Name: fxName, Num: fxNum}} + if !intentsEqual(intents, want) { + t.Fatalf("intents = %v, want %v", intents, want) + } + if next.Load != LoadLoading { + t.Errorf("Load = %v, want LoadLoading", next.Load) + } + if next.Err != nil { + t.Error("the old error survived the retry") + } + + // The other half: inert on a healthy screen. Paired with the assertion + // above, so "zero" here is a claim about the branch. + _, none := ready(t).Step(keyPress("r")) + if len(none) != 0 { + t.Errorf("`r` on a healthy screen emitted %v", none) + } +} + +// --- focus and navigation ---------------------------------------------------- + +// ⚠ THE DIFF IS THE DEFAULT FOCUS, NOT THE FILES PANEL. Measured: the median PR +// in this repo touches ONE file, so landing on Files wastes a keystroke on +// every single review. +func TestFocusStartsOnTheDiffPanel(t *testing.T) { + if got := New(fxOwner, fxName, fxNum).Focus; got != FocusDefault { + t.Errorf("Focus = %v, want %v", got, FocusDefault) + } + if FocusDefault != PanelDiff { + t.Errorf("FocusDefault = %v, want PanelDiff", FocusDefault) + } +} + +func TestTabCyclesAllFourPanelsAndWrapsAround(t *testing.T) { + a := ready(t) + seen := []Panel{a.Focus} + for i := 0; i < int(panelCount); i++ { + var intents []Intent + a, intents = a.Step(keyPress("tab")) + if len(intents) != 0 { + t.Errorf("tab emitted %v — focus is local", intents) + } + seen = append(seen, a.Focus) + } + // Four distinct panels, then back to where it started. + distinct := map[Panel]bool{} + for _, p := range seen { + distinct[p] = true + } + if len(distinct) != int(panelCount) { + t.Errorf("tab visited %d distinct panels, want %d: %v", len(distinct), panelCount, seen) + } + if seen[0] != seen[len(seen)-1] { + t.Errorf("tab did not wrap: started at %v, ended at %v", seen[0], seen[len(seen)-1]) + } +} + +func TestShiftTabCyclesBackwards(t *testing.T) { + a := ready(t) + start := a.Focus + fwd, _ := a.Step(keyPress("tab")) + back, _ := fwd.Step(keyPress("shift+tab")) + if back.Focus != start { + t.Errorf("tab then S-tab landed on %v, want %v", back.Focus, start) + } +} + +// 🔴 AN ISSUE HAS NO COMMITS, FILES OR DIFF, so `tab` must not park the cursor +// on three empty boxes. +func TestTabOnAnIssueStaysOnOverview(t *testing.T) { + a := New(fxOwner, fxName, 1656) + a.Width, a.Height = 140, 40 + a, _ = a.Step(PRLoaded{Snap: fixtureIssue()}) + for i := 0; i < 5; i++ { + a, _ = a.Step(keyPress("tab")) + if a.Focus != PanelOverview { + t.Fatalf("tab %d landed on %v; an issue has only the Overview", i, a.Focus) + } + } +} + +// Selecting a file moves the diff cursor into that file. 🔴 THE CROSS-PANEL +// EFFECT GOES THROUGH THE ROOT — the Files panel holds no pointer to the Diff +// panel. +func TestSelectingAFileMovesTheDiffCursorIntoIt(t *testing.T) { + a := ready(t) + a.Focus = PanelFiles + if len(a.Snap.Files) < 2 { + t.Fatal("fixture needs at least two files for this to mean anything") + } + next, _ := a.Step(keyPress("j")) + if next.fileCur != 1 { + t.Fatalf("fileCur = %d, want 1", next.fileCur) + } + wantStart := next.Diff.FileStart(1) + if next.diffCur != wantStart { + t.Errorf("diffCur = %d, want %d (the start of file 1)", next.diffCur, wantStart) + } + // And the reverse link: moving the diff cursor back into file 0 moves the + // Files highlight with it. + next.Focus = PanelDiff + back, _ := next.Step(keyPress("{")) + if back.fileCur != 0 { + t.Errorf("after `{`, fileCur = %d, want 0", back.fileCur) + } +} + +func TestHunkNavigationMovesTheDiffCursorToHunkHeaders(t *testing.T) { + a := ready(t) + a.Focus = PanelDiff + if len(a.Diff.Hunks) < 2 { + t.Fatal("fixture needs at least two hunks") + } + next, intents := a.Step(keyPress("]")) + if len(intents) != 0 { + t.Errorf("`]` emitted %v — navigation is local", intents) + } + if next.diffCur != a.Diff.Hunks[0].LineIndex { + t.Errorf("diffCur = %d, want %d", next.diffCur, a.Diff.Hunks[0].LineIndex) + } + next2, _ := next.Step(keyPress("]")) + if next2.diffCur != a.Diff.Hunks[1].LineIndex { + t.Errorf("second `]` -> %d, want %d", next2.diffCur, a.Diff.Hunks[1].LineIndex) + } + // At the last hunk `]` is inert rather than wrapping or going out of range. + last := next2 + for i := 0; i < 5; i++ { + last, _ = last.Step(keyPress("]")) + } + if last.diffCur >= len(a.Diff.Lines) { + t.Errorf("diffCur ran past the buffer: %d >= %d", last.diffCur, len(a.Diff.Lines)) + } + back, _ := next2.Step(keyPress("[")) + if back.diffCur != a.Diff.Hunks[0].LineIndex { + t.Errorf("`[` -> %d, want %d", back.diffCur, a.Diff.Hunks[0].LineIndex) + } +} + +func TestCursorMovementNeverLeavesTheBuffer(t *testing.T) { + a := ready(t) + a.Focus = PanelDiff + for i := 0; i < len(a.Diff.Lines)+20; i++ { + a, _ = a.Step(keyPress("j")) + } + if a.diffCur != len(a.Diff.Lines)-1 { + t.Errorf("after running off the bottom, diffCur = %d, want %d", + a.diffCur, len(a.Diff.Lines)-1) + } + for i := 0; i < len(a.Diff.Lines)+20; i++ { + a, _ = a.Step(keyPress("k")) + } + if a.diffCur != 0 { + t.Errorf("after running off the top, diffCur = %d, want 0", a.diffCur) + } +} + +func TestAnUnboundKeyChangesNothing(t *testing.T) { + a := ready(t) + next, intents := a.Step(keyPress("Z")) + if len(intents) != 0 { + t.Errorf("an unbound key emitted %v", intents) + } + if next.Focus != a.Focus || next.diffCur != a.diffCur || next.Quitting { + t.Error("an unbound key changed state") + } +} + +// --- the rendered screen ----------------------------------------------------- + +// 🔴 §10.2 — THE AUTHENTICATED LOGIN IS ON SCREEN. cli/cli#14370: the OS keyring +// is not partitioned by account, so the resolved token can belong to a DIFFERENT +// account than the config's active one, and this host's `hosts.yml` carries two +// github.com users. For a tool that will be able to merge, that is the +// difference between approving as yourself and approving as somebody else. +// +// ⚠ THE FIXTURE LOGIN IS `a-reviewer`, WHICH IS NOT THE AUTHOR AND NOT ANY +// OTHER FIXTURE FIELD — so a mutant that rendered the author, or a constant, +// cannot produce it. +func TestTheViewerLoginIsRenderedInTheOverviewAndTheFooter(t *testing.T) { + a := ready(t) + screen := stripANSI(a.render()) + if !strings.Contains(screen, "a-reviewer") { + t.Fatalf("the authenticated login is NOT on screen:\n%s", screen) + } + if !strings.Contains(stripANSI(a.overviewBody(80, 20)), "a-reviewer") { + t.Error("the Overview panel does not carry the viewer login") + } + if !strings.Contains(stripANSI(a.renderFooter()), "a-reviewer") { + t.Error("the footer does not carry the viewer login") + } + // POSITIVE CONTROL for the matcher: the author is a DIFFERENT string and + // is also on screen, so `Contains` is not matching everything. + if !strings.Contains(screen, "an-author") { + t.Error("the author is missing too — the matcher may be broken") + } +} + +func TestTheIssueCardSaysItIsNotAPullRequest(t *testing.T) { + a := New(fxOwner, fxName, 1656) + a.Width, a.Height = 140, 40 + a, _ = a.Step(PRLoaded{Snap: fixtureIssue()}) + screen := stripANSI(a.render()) + + for _, want := range []string{ + "ISSUE", + "not a pull request", + "Widget refresh drops the stale context", + "an-author", + "a-reviewer", + } { + if !strings.Contains(screen, want) { + t.Errorf("the issue card does not carry %q\n%s", want, screen) + } + } + // 🔴 AND IT IS NOT A PR SCREEN. The four-panel titles must be absent, or + // the card is rendering over the wrong layout. + for _, absent := range []string{"2 Commits", "3 Files", "4 Diff"} { + if strings.Contains(screen, absent) { + t.Errorf("the issue card rendered the PR panel %q", absent) + } + } +} + +func TestTheOverviewCarriesEveryStateWord(t *testing.T) { + a := ready(t) + body := stripANSI(a.overviewBody(80, 20)) + // Each of these is derived from the fixture BY HAND, not read off the + // implementation: CHANGES_REQUESTED -> CHANGES, MERGEABLE+BLOCKED -> + // BLOCKED, 3 failing of 7 -> "3 FAILING", 3 unresolved -> "3 UNRESOLVED". + for _, want := range []string{"CHANGES", "BLOCKED", "3 FAILING", "3 UNRESOLVED", "OPEN"} { + if !strings.Contains(body, want) { + t.Errorf("the Overview does not carry %q\n%s", want, body) + } + } +} + +func TestAWindowTooSmallSaysSoInWords(t *testing.T) { + a := ready(t) + a.Width, a.Height = 20, 5 + screen := stripANSI(a.render()) + if !strings.Contains(screen, "WINDOW TOO SMALL") { + t.Errorf("a tiny window rendered a mangled frame instead of a word:\n%s", screen) + } +} + +func TestAWindowSizeMessageIsAbsorbedWithoutIntents(t *testing.T) { + a := ready(t) + next, intents := a.Step(tea.WindowSizeMsg{Width: 200, Height: 60}) + if len(intents) != 0 { + t.Errorf("a resize emitted %v", intents) + } + if next.Width != 200 || next.Height != 60 { + t.Errorf("size = %dx%d, want 200x60", next.Width, next.Height) + } +} + +// --- helpers ----------------------------------------------------------------- + +func intentsEqual(got, want []Intent) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +// stubRunner satisfies Runner without touching the network. +type stubRunner struct{} + +func (stubRunner) FetchPR(context.Context, string, string, int) (*ghapi.Snapshot, error) { + return fixturePR(), nil +} +func (stubRunner) FetchDiff(context.Context, string, string, int) (*udiff.Diff, error) { + return &udiff.Diff{}, nil +} +func (stubRunner) OpenBrowser(string) error { return nil } diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/cards.go b/nix/pkgs/tools/mention-review/src/internal/ui/cards.go new file mode 100644 index 000000000..e3b79947f --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/cards.go @@ -0,0 +1,80 @@ +package ui + +import ( + "errors" + "fmt" + "strings" + + "github.com/innovation-upstream/devrc/mention-review/internal/ghapi" +) + +// 🔴 A WINDOW THAT FLASHES AND VANISHES IS THE WORST OUTCOME AND MUST NEVER +// HAPPEN. +// +// Alacritty exits 0 whether its `-e` command exits 0 or 127, so an exit code +// teaches the operator nothing. Every failure below therefore renders a +// readable card and WAITS FOR `q` — it does not exit, and it does not leave a +// blank pane that reads as "GitHub is down". +// +// The one exception is malformed argv, which exits 64/65/66 BEFORE anything is +// drawn — the same contract `nvim-octo` has today, and the only case where +// there is no window to keep open. + +// errorCardBody is the prose under the state WORD. +func errorCardBody(repo string, num int, err error) string { + var ae *ghapi.APIError + if !errors.As(err, &ae) { + return strings.Join([]string{ + fmt.Sprintf("%s#%d", repo, num), + "", + err.Error(), + "", + "`o` opens it in the browser · `r` retries · `q` closes", + }, "\n") + } + + lines := []string{fmt.Sprintf("%s#%d", repo, num), ""} + switch ae.State { + case ghapi.AuthNoToken: + // 🔴 EXPLICITLY DISTINGUISHED FROM A 401, BECAUSE THE FIXES DIFFER. + // go-gh's fourth rung returns ("", "default") with NO error, so an + // empty token is a value rather than a failure — a client that checked + // only `err` would show a 401 card here and send the operator to debug + // a token that does not exist. + lines = append(lines, + "No GitHub token could be resolved for github.com.", + "", + "Run `gh auth login`.") + case ghapi.AuthRejected: + lines = append(lines, + "A token was found and GitHub refused it.", + "", + "It may be expired, revoked, or missing the scopes this needs.", + "Run `gh auth status` and then `gh auth login` if needed.") + case ghapi.AuthNotFound: + lines = append(lines, + ae.Detail, + "", + "A browser session may have access this token does not —") + case ghapi.AuthRateLimited: + if !ae.ResetAt.IsZero() { + lines = append(lines, + "GitHub's rate limit is spent.", + "", + "Resets at "+ae.ResetAt.Local().Format("15:04")+".") + } else { + lines = append(lines, "GitHub's rate limit is spent.") + } + default: + lines = append(lines, ae.Detail) + } + lines = append(lines, "", "`o` opens it in the browser · `r` retries · `q` closes") + return strings.Join(lines, "\n") +} + +// errorsAs is a tiny shim so panels.go does not import `errors` purely for one +// call — and so the two files cannot disagree about how an APIError is +// recognised. +func errorsAs(err error, target **ghapi.APIError) bool { + return errors.As(err, target) +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/e2e_test.go b/nix/pkgs/tools/mention-review/src/internal/ui/e2e_test.go new file mode 100644 index 000000000..167baf449 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/e2e_test.go @@ -0,0 +1,202 @@ +package ui + +import ( + "context" + "io" + "strings" + "sync" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/innovation-upstream/devrc/mention-review/internal/ghapi" + "github.com/innovation-upstream/devrc/mention-review/internal/udiff" +) + +// 🔴 LAYER 4 — EXACTLY ONE END-TO-END TEST. +// +// It does only what the layers above structurally cannot: prove the WIRING. +// Fake effects -> a real `tea.Program` -> scripted keys -> assertions on the +// MODEL'S FINAL STATE, never on a frame. Its value is catching "the panels +// never got wired to the fetched data", which every isolated test passes. Its +// cost is being the flakiest test in the suite, so there is ONE. +// +// 🔴 `tea.WithoutRenderer()` — a headless program, deliberately chosen over +// `x/exp/teatest/v2`. teatest has NO semver tag (pseudo-versions only) and +// lives under `exp/` with "no backwards compatibility guarantees", which is a +// poor fit for a vendorHash-pinned Nix build; its `Output()` returns the raw +// byte stream of every frame the renderer emitted rather than a screen; and +// Bubble Tea v2's capability handshake injects environment-dependent bytes into +// that stream. It is the tool for golden frames, and those are rejected. +// +// 🔴 IT OPENS NO WINDOW AND TOUCHES NO TERMINAL. Input is a pipe, output is +// discarded, and there is no renderer at all. + +// fakeRunner records what was asked for and answers from fixtures. +type fakeRunner struct { + mu sync.Mutex + prCalls int + difCalls int + opened []string + gotOwner string + gotName string + gotNum int + diffErr error +} + +func (f *fakeRunner) FetchPR(_ context.Context, owner, name string, num int) (*ghapi.Snapshot, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.prCalls++ + f.gotOwner, f.gotName, f.gotNum = owner, name, num + return fixturePR(), nil +} + +func (f *fakeRunner) FetchDiff(_ context.Context, _, _ string, _ int) (*udiff.Diff, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.difCalls++ + if f.diffErr != nil { + return nil, f.diffErr + } + d, err := udiff.Parse([]udiff.FileInput{ + {Path: "pkg/handler.go", ChangeType: "MODIFIED", Additions: 9, Deletions: 1, + Patch: "@@ -12,1 +12,2 @@ func handle(req *Request) error {\n ctx := build(req)\n+\tctx.refresh()\n"}, + {Path: "pkg/widget.go", ChangeType: "ADDED", Additions: 3, + Patch: "@@ -0,0 +1,3 @@\n+package widget\n+\n+const Name = \"widget\"\n"}, + }) + return d, err +} + +func (f *fakeRunner) OpenBrowser(url string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.opened = append(f.opened, url) + return nil +} + +func (f *fakeRunner) counts() (pr, dif int, opened []string) { + f.mu.Lock() + defer f.mu.Unlock() + return f.prCalls, f.difCalls, append([]string(nil), f.opened...) +} + +func TestEndToEndTheProgramFetchesWiresAndQuits(t *testing.T) { + fake := &fakeRunner{} + + app := New(fxOwner, fxName, fxNum) + app.SetRunner(fake) + + inR, inW := io.Pipe() + defer inW.Close() + + p := tea.NewProgram(app, + tea.WithInput(inR), + tea.WithOutput(io.Discard), + tea.WithoutRenderer(), + tea.WithoutSignalHandler(), + tea.WithWindowSize(140, 40), + ) + + done := make(chan tea.Model, 1) + errc := make(chan error, 1) + go func() { + final, err := p.Run() + errc <- err + done <- final + }() + + // Wait for BOTH reads to land, rather than sleeping a fixed interval. + deadline := time.Now().Add(10 * time.Second) + for { + pr, dif, _ := fake.counts() + if pr >= 1 && dif >= 1 { + break + } + if time.Now().After(deadline) { + t.Fatalf("the program never completed its reads: pr=%d diff=%d", pr, dif) + } + time.Sleep(5 * time.Millisecond) + } + + // Script some keys through the REAL event loop. + p.Send(keyPress("tab")) + p.Send(keyPress("]")) + p.Send(keyPress("o")) + time.Sleep(50 * time.Millisecond) + p.Send(keyPress("q")) + + select { + case err := <-errc: + if err != nil { + t.Fatalf("program error: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("the program did not exit after `q`") + } + final := <-done + + got, ok := final.(App) + if !ok { + t.Fatalf("final model is %T, want App", final) + } + + // --- the wiring assertions, on STATE ------------------------------------- + + pr, dif, opened := fake.counts() + + // 🔴 EXACTLY ONE GRAPHQL READ. This is the Phase-0 kill criterion, held at + // the level where a retry loop or a duplicated Init would break it. + if pr != 1 { + t.Errorf("FetchPR was called %d times, want exactly 1", pr) + } + if dif != 1 { + t.Errorf("FetchDiff was called %d times, want exactly 1", dif) + } + // The argv reached the client unmangled — the whole point of the seam. + if fake.gotOwner != fxOwner || fake.gotName != fxName || fake.gotNum != fxNum { + t.Errorf("the client was asked for %s/%s#%d, want %s/%s#%d", + fake.gotOwner, fake.gotName, fake.gotNum, fxOwner, fxName, fxNum) + } + + // 🔴 THE PANELS GOT WIRED TO THE FETCHED DATA. This is the defect every + // isolated test passes: each component works, and nothing ever built the + // combined state. + if got.Snap == nil { + t.Fatal("the fetched snapshot never reached the model") + } + if got.Snap.ViewerLogin != "a-reviewer" { + t.Errorf("ViewerLogin = %q", got.Snap.ViewerLogin) + } + if got.Diff == nil { + t.Fatal("the fetched diff never reached the model") + } + if len(got.Diff.Files) != 2 { + t.Errorf("the diff carries %d files, want 2", len(got.Diff.Files)) + } + if got.Load != LoadReady { + t.Errorf("Load = %v, want LoadReady", got.Load) + } + + // The scripted keys actually took effect through the real loop. + if got.Focus == FocusDefault { + t.Errorf("`tab` did not move focus off %v", FocusDefault) + } + if len(opened) != 1 || !strings.HasSuffix(opened[0], "/pull/1559") { + t.Errorf("`o` opened %v, want exactly one PR URL", opened) + } + if !got.Quitting { + t.Error("the final model does not record the quit") + } + + // And the screen it would draw is populated — one rendering assertion, on + // CONTENT rather than on bytes, because a program that wired everything and + // then rendered an empty frame is still broken. + screen := stripANSI(got.render()) + for _, want := range []string{"pkg/handler.go", "a-reviewer", "3 FAILING"} { + if !strings.Contains(screen, want) { + t.Errorf("the final screen is missing %q", want) + } + } +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/intents.go b/nix/pkgs/tools/mention-review/src/internal/ui/intents.go new file mode 100644 index 000000000..c725e9d55 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/intents.go @@ -0,0 +1,76 @@ +package ui + +// 🔴 THE COMMAND SEAM (§3.3) — the single most important design decision for +// testability, and it has to be in the design from day one because retrofitting +// it means rewriting every handler. +// +// `Step` is a PURE function. It may not perform I/O and it may not construct a +// closure that performs I/O, because a `tea.Cmd` is an opaque `func() tea.Msg` +// and a test cannot assert anything about one. So `Step` returns a TAGGED +// INTENT — plain data — and one thin, separately-tested runner (`run.go`) +// converts intents into `tea.Cmd`s. +// +// That is what makes an assertion like "pressing this key produces zero network +// intents" a real, mechanical test rather than a golden-frame snapshot. + +// Intent is a request for the outside world, as data. +// +// ⚠ NO `Write() bool` YET, AND THAT IS A PHASING DECISION, NOT AN OVERSIGHT. +// Phase 1 is read-only: every intent below returns false, so the predicate and +// the confirmation ledger built on it were a provable constant. Phase 2 — the +// first write verb — MUST reintroduce both, and it can: the ledger is purely +// additive over this seam and needs no handler rewritten. It is the SEAM that +// had to exist from day one, not the ledger over it. +type Intent interface { + // intentName is the registry key. Unexported so nothing outside this + // package can add an intent the registry has never seen. + intentName() string +} + +// --- the intents Phase 1 can emit ------------------------------------------- + +// FetchPR is the one GraphQL read. +type FetchPR struct { + Owner string + Name string + Num int +} + +func (FetchPR) intentName() string { return "FetchPR" } + +// FetchDiff is the REST files+patch read. +type FetchDiff struct { + Owner string + Name string + Num int +} + +func (FetchDiff) intentName() string { return "FetchDiff" } + +// OpenBrowser hands a URL to xdg-open. +// +// ⚠ IT IS NOT A GITHUB WRITE. It leaves this machine, but it changes nothing on +// the server, so when Phase 2 reintroduces the write predicate this one stays +// false — a judgement worth writing down rather than leaving to be re-derived. +type OpenBrowser struct{ URL string } + +func (OpenBrowser) intentName() string { return "OpenBrowser" } + +// --- the registry ----------------------------------------------------------- + +// KnownIntents is the REGISTRY. Every intent type this package can emit appears +// here exactly once. +// +// 🔴 A REGISTRY IS UNAVOIDABLE IN GO: there is no way to enumerate every +// implementation of an interface at run time. So the registry is made +// load-bearing instead of decorative — `intents_test.go` drives `Step` over +// EVERY binding in `Dispatch()` and asserts that every intent it emits is +// registered. An unregistered intent therefore fails the suite at the moment a +// key can produce it, not at the moment someone remembers to list it. +func KnownIntents() []Intent { + return []Intent{ + FetchPR{}, + FetchDiff{}, + OpenBrowser{}, + } +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/intents_test.go b/nix/pkgs/tools/mention-review/src/internal/ui/intents_test.go new file mode 100644 index 000000000..acc69db13 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/intents_test.go @@ -0,0 +1,122 @@ +package ui + +import ( + "sort" + "testing" + + "github.com/innovation-upstream/devrc/mention-review/internal/ghapi" +) + +// 🔴 LAYER 3(b) — THE REGISTRY, DRIVEN FROM THE KEYBOARD. +// +// ⚠ THE §3.7 CONFIRMATION LEDGER IS NOT HERE, AND ITS ABSENCE IS THE POINT. +// Phase 1 emits no write intents, so `LedgerViolations(KnownIntents())` was a +// provable constant `nil` over two empty map literals — a guard that READS as +// coverage while providing none, which this repo holds to be worse than no +// guard. It is deleted rather than carried: the Intent SEAM is what had to +// exist from day one, and the ledger is purely additive over it. 🔴 PHASE 2 — +// the first write verb — MUST REINTRODUCE IT, with the positive control that +// version carried (a test-local write intent in neither set, reported with the +// guard's own error string). +// +// What survives is the half that was never vacuous: the registry is driven from +// `Dispatch()` over every reachable app state, and every registered intent must +// be handled by `Run`. + +// unregisteredIntent exists ONLY in this file. It is the positive control for +// `Run`'s panic backstop below: an intent `Run` does not handle must panic, or +// the loop that walks the registry proves nothing. +type unregisteredIntent struct{} + +func (unregisteredIntent) intentName() string { return "TestOnlyUnregistered" } + +// 🔴 THIS IS WHAT MAKES THE REGISTRY LOAD-BEARING RATHER THAN DECORATIVE. +// +// Go cannot enumerate every implementation of an interface at run time, so the +// registry has to be hand-written — which is exactly the shape that rots. This +// drives `Step` over EVERY binding in `Dispatch()`, in every reachable app +// state, and asserts that every intent it emits is registered. An unregistered +// intent therefore fails the suite at the moment a KEY can produce it, not at +// the moment someone remembers to list it. +func TestEveryIntentStepCanEmitIsRegistered(t *testing.T) { + registered := map[string]bool{} + for _, i := range KnownIntents() { + registered[i.intentName()] = true + } + + states := map[string]App{ + "loading": New("gardenersguild", "trowelcast", 1559), + "ready-pr": func() App { + a, _ := New("gardenersguild", "trowelcast", 1559).Step(PRLoaded{Snap: fixturePR()}) + return a + }(), + "ready-issue": func() App { + a, _ := New("gardenersguild", "trowelcast", 1559).Step(PRLoaded{Snap: fixtureIssue()}) + return a + }(), + "failed": func() App { + a, _ := New("gardenersguild", "trowelcast", 1559).Step( + PRLoaded{Err: &ghapi.APIError{State: ghapi.AuthNoToken}}) + return a + }(), + } + + seen := map[string]bool{} + for name, app := range states { + for _, b := range Dispatch() { + for _, k := range b.Binding.Keys() { + _, intents := app.Step(keyPress(k)) + for _, i := range intents { + n := i.intentName() + seen[n] = true + if !registered[n] { + t.Errorf("state %q key %q (%s) emitted intent %q, "+ + "which is not in KnownIntents()", name, k, b.Action, n) + } + } + } + } + } + + // 🔴 POSITIVE CONTROL: the walk must have SEEN intents. A walk that emitted + // nothing would pass the loop above identically, and "every intent it + // emitted was registered" over an empty set is the silent zero. + if len(seen) == 0 { + t.Fatal("the dispatch walk emitted NO intents at all — this guard " + + "observed nothing") + } + names := make([]string, 0, len(seen)) + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + t.Logf("dispatch walk emitted %d distinct intents: %v", len(seen), names) +} + +// Every registered intent must be handled by `Run`. An unhandled one is a +// keypress that does nothing, forever, with no error anywhere. +func TestEveryRegisteredIntentIsHandledByRun(t *testing.T) { + for _, i := range KnownIntents() { + func() { + defer func() { + if r := recover(); r != nil { + t.Errorf("Run does not handle %q: %v", i.intentName(), r) + } + }() + if cmd := Run(i, stubRunner{}); cmd == nil { + t.Errorf("Run(%q) returned a nil command", i.intentName()) + } + }() + } + // POSITIVE CONTROL on the panic detector: an intent Run does NOT handle + // must panic, or the loop above proves nothing. + func() { + defer func() { + if r := recover(); r == nil { + t.Error("Run accepted an unhandled intent without panicking — " + + "a new intent could be added and silently do nothing") + } + }() + _ = Run(unregisteredIntent{}, stubRunner{}) + }() +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/keys.go b/nix/pkgs/tools/mention-review/src/internal/ui/keys.go new file mode 100644 index 000000000..7cee55b8f --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/keys.go @@ -0,0 +1,160 @@ +package ui + +import "charm.land/bubbles/v2/key" + +// 🔴 THE HELP FOOTER IS GENERATED, AND IT CANNOT GO STALE. +// +// The immediately-preceding arc existed ONLY because 131 keybindings were +// invisible and the operator asked "how do I merge?". A hand-written legend is +// the exact defect that arc fixed, and reintroducing one here would be undoing +// it one layer down. +// +// So there is EXACTLY ONE PLACE A BINDING IS SPELLED. The dispatcher reads +// `Keys.NextPanel`; the footer reads `Keys.NextPanel`. They cannot disagree +// because they are the same value. And because "they are the same value" is +// a property of how this file happens to be written today, `keys_test.go` adds +// a TWO-WAY ledger test that fails when the dispatched set GROWS past the +// helped set (a binding with no help) and when it SHRINKS below it (a help +// entry for a key that does nothing — a legend that lies). + +// KeyMap is the single source of truth for every binding. +type KeyMap struct { + NextPanel key.Binding + PrevPanel key.Binding + + Up key.Binding + Down key.Binding + PageUp key.Binding + PageDown key.Binding + Top key.Binding + Bottom key.Binding + + NextHunk key.Binding + PrevHunk key.Binding + NextFile key.Binding + PrevFile key.Binding + + Browser key.Binding + Retry key.Binding + FullHelpToggle key.Binding + Quit key.Binding +} + +// Keys is the live map. +// +// ⚠ v2 NOTE: match `tea.KeyPressMsg`, not `tea.KeyMsg` — the latter is an +// INTERFACE in Bubble Tea v2. And a space is spelled `"space"`, not `" "`. +var Keys = KeyMap{ + NextPanel: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "panel")), + PrevPanel: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("S-tab", "prev panel")), + + Up: key.NewBinding(key.WithKeys("k", "up"), key.WithHelp("k/↑", "up")), + Down: key.NewBinding(key.WithKeys("j", "down"), key.WithHelp("j/↓", "down")), + PageUp: key.NewBinding(key.WithKeys("ctrl+u", "pgup"), key.WithHelp("C-u", "half page up")), + PageDown: key.NewBinding(key.WithKeys("ctrl+d", "pgdown"), key.WithHelp("C-d", "half page down")), + Top: key.NewBinding(key.WithKeys("g", "home"), key.WithHelp("g", "top")), + Bottom: key.NewBinding(key.WithKeys("G", "end"), key.WithHelp("G", "bottom")), + + NextHunk: key.NewBinding(key.WithKeys("]"), key.WithHelp("]", "next hunk")), + PrevHunk: key.NewBinding(key.WithKeys("["), key.WithHelp("[", "prev hunk")), + NextFile: key.NewBinding(key.WithKeys("}"), key.WithHelp("}", "next file")), + PrevFile: key.NewBinding(key.WithKeys("{"), key.WithHelp("{", "prev file")), + + Browser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")), + Retry: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "retry")), + FullHelpToggle: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "keys")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c", "esc"), key.WithHelp("q", "quit")), +} + +// Action names the handler a binding dispatches to. It is a VALUE, not a +// closure: the ledger test compares SETS of these against the help entries, +// and a set of opaque funcs cannot be compared. +type Action string + +const ( + ActNextPanel Action = "NextPanel" + ActPrevPanel Action = "PrevPanel" + ActUp Action = "Up" + ActDown Action = "Down" + ActPageUp Action = "PageUp" + ActPageDown Action = "PageDown" + ActTop Action = "Top" + ActBottom Action = "Bottom" + ActNextHunk Action = "NextHunk" + ActPrevHunk Action = "PrevHunk" + ActNextFile Action = "NextFile" + ActPrevFile Action = "PrevFile" + ActBrowser Action = "Browser" + ActRetry Action = "Retry" + ActFullHelp Action = "FullHelp" + ActQuit Action = "Quit" +) + +// Bound pairs a binding with the action it dispatches to. +type Bound struct { + Action Action + Binding key.Binding +} + +// Dispatch is THE dispatch table. `Step` walks exactly this slice; nothing +// else maps a keypress to behaviour. +// +// 🔴 A DECLARED TABLE, NOT A `switch`, AND THAT IS WHY THE LEDGER TEST IS +// HONEST. §5.3(a) requires walking "Step's dispatch (a declared []binding +// table, not a regex over source)". A `switch` would force the test to grep +// this file, which measures the SPELLING rather than the behaviour — and a +// grep-based ledger passes over a `case` that falls through to nothing. +func Dispatch() []Bound { + return []Bound{ + {ActNextPanel, Keys.NextPanel}, + {ActPrevPanel, Keys.PrevPanel}, + {ActUp, Keys.Up}, + {ActDown, Keys.Down}, + {ActPageUp, Keys.PageUp}, + {ActPageDown, Keys.PageDown}, + {ActTop, Keys.Top}, + {ActBottom, Keys.Bottom}, + {ActNextHunk, Keys.NextHunk}, + {ActPrevHunk, Keys.PrevHunk}, + {ActNextFile, Keys.NextFile}, + {ActPrevFile, Keys.PrevFile}, + {ActBrowser, Keys.Browser}, + {ActRetry, Keys.Retry}, + {ActFullHelp, Keys.FullHelpToggle}, + {ActQuit, Keys.Quit}, + } +} + +// ShortHelp is the persistent footer row — on by default, as the operator +// asked. It is a SUBSET of FullHelp, chosen for width, not a second list with +// its own text. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.NextPanel, k.Down, k.NextHunk, k.NextFile, k.Browser, k.FullHelpToggle, k.Quit, + } +} + +// FullHelp is what `?` expands to, GROUPED BY PANEL — an expansion of the same +// generated data, never a separate modal legend with its own text. +// +// 🔴 EVERY BINDING IN `Dispatch()` MUST APPEAR HERE, AND NOTHING ELSE MAY. +// `keys_test.go` asserts set equality in both directions. +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.NextPanel, k.PrevPanel}, + {k.Up, k.Down, k.PageUp, k.PageDown, k.Top, k.Bottom}, + {k.NextHunk, k.PrevHunk, k.NextFile, k.PrevFile}, + {k.Browser, k.Retry, k.FullHelpToggle, k.Quit}, + } +} + +// helpedBindings flattens FullHelp. Used by the ledger test and by nothing +// else — it is here rather than in the test file so the test cannot quietly +// flatten a DIFFERENT structure from the one the footer renders. +func (k KeyMap) helpedBindings() []key.Binding { + var out []key.Binding + for _, g := range k.FullHelp() { + out = append(out, g...) + } + return out +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/keys_test.go b/nix/pkgs/tools/mention-review/src/internal/ui/keys_test.go new file mode 100644 index 000000000..17b664c0c --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/keys_test.go @@ -0,0 +1,289 @@ +package ui + +import ( + "sort" + "strings" + "testing" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +// 🔴 LAYER 3(a) — THE KEYMAP ↔ HELP LEDGER, TWO-WAY. +// +// This is what makes "the footer cannot go stale" a MECHANICAL FACT rather than +// a promise. It fails when the dispatched set GROWS past the helped set (a +// binding with no help — the exact defect the preceding arc existed to fix) and +// when it SHRINKS below it (a help entry for a key that does nothing — a legend +// that lies). +// +// 🔴 IT WALKS `Dispatch()`, WHICH IS A DECLARED TABLE, NOT A REGEX OVER SOURCE. +// A grep-based ledger measures the SPELLING rather than the behaviour, and it +// passes over a `case` that falls through to nothing. + +// bindingID is the identity two sets are compared on. 🔴 THE KEYS, NOT THE HELP +// TEXT: comparing on help text would let a binding with the right label and the +// wrong keys pass, which is the failure the operator would actually hit. +func bindingID(b key.Binding) string { + ks := append([]string(nil), b.Keys()...) + sort.Strings(ks) + return strings.Join(ks, "|") +} + +func idsOf(bs []key.Binding) []string { + out := make([]string, 0, len(bs)) + for _, b := range bs { + out = append(out, bindingID(b)) + } + sort.Strings(out) + return out +} + +func TestEveryDispatchedBindingHasAHelpEntryAndViceVersa(t *testing.T) { + var dispatched []key.Binding + for _, b := range Dispatch() { + dispatched = append(dispatched, b.Binding) + } + helped := Keys.helpedBindings() + + dset := map[string]bool{} + for _, id := range idsOf(dispatched) { + dset[id] = true + } + hset := map[string]bool{} + for _, id := range idsOf(helped) { + hset[id] = true + } + + // Direction 1 — the set GREW: a binding is dispatched on but absent from + // FullHelp. This is the defect the preceding arc existed to fix. + for _, b := range Dispatch() { + if !hset[bindingID(b.Binding)] { + t.Errorf("binding %q (%s) is dispatched on but absent from FullHelp", + bindingID(b.Binding), b.Action) + } + } + // Direction 2 — the set SHRANK: a help entry for a key that does nothing. + for _, b := range helped { + if !dset[bindingID(b)] { + t.Errorf("help entry %q is dispatched on nowhere", bindingID(b)) + } + } +} + +// 🔴 POSITIVE CONTROL ON THE LEDGER ITSELF. The test above can only be believed +// if its comparison CAN fail — a matcher that considers everything equal would +// pass identically. This feeds it a deliberately unhelped binding and a +// deliberately undispatched help entry and asserts it reports BOTH, with each +// direction's own wording. +func TestTheLedgerComparisonCanActuallyGoRed(t *testing.T) { + ghost := key.NewBinding(key.WithKeys("ctrl+alt+z"), key.WithHelp("C-A-z", "ghost")) + orphan := key.NewBinding(key.WithKeys("ctrl+alt+y"), key.WithHelp("C-A-y", "orphan")) + + dispatched := []key.Binding{Keys.Quit, ghost} + helped := []key.Binding{Keys.Quit, orphan} + + grew, shrank := ledgerDiff(dispatched, helped) + if len(grew) != 1 || grew[0] != bindingID(ghost) { + t.Errorf("GREW direction reported %v, want exactly [%s]", grew, bindingID(ghost)) + } + if len(shrank) != 1 || shrank[0] != bindingID(orphan) { + t.Errorf("SHRANK direction reported %v, want exactly [%s]", shrank, bindingID(orphan)) + } + // And the negative control: identical sets report nothing in either + // direction. Without this the pair above is satisfied by a function that + // reports everything. + g2, s2 := ledgerDiff(dispatched, dispatched) + if len(g2) != 0 || len(s2) != 0 { + t.Errorf("identical sets reported grew=%v shrank=%v, want empty", g2, s2) + } +} + +// ledgerDiff is the comparison the ledger test performs, extracted so the +// positive control above exercises the SAME code rather than a second copy of +// it. A control built out of a re-implementation controls nothing. +func ledgerDiff(dispatched, helped []key.Binding) (grew, shrank []string) { + dset, hset := map[string]bool{}, map[string]bool{} + for _, b := range dispatched { + dset[bindingID(b)] = true + } + for _, b := range helped { + hset[bindingID(b)] = true + } + for _, b := range dispatched { + if !hset[bindingID(b)] { + grew = append(grew, bindingID(b)) + } + } + for _, b := range helped { + if !dset[bindingID(b)] { + shrank = append(shrank, bindingID(b)) + } + } + sort.Strings(grew) + sort.Strings(shrank) + return grew, shrank +} + +// ShortHelp is a SUBSET of FullHelp, not a second list with its own text. A +// short-help entry that is not in full help is a legend nobody can expand. +func TestShortHelpIsASubsetOfFullHelp(t *testing.T) { + full := map[string]bool{} + for _, b := range Keys.helpedBindings() { + full[bindingID(b)] = true + } + for _, b := range Keys.ShortHelp() { + if !full[bindingID(b)] { + t.Errorf("ShortHelp carries %q, which FullHelp does not", bindingID(b)) + } + } + if len(Keys.ShortHelp()) == 0 { + t.Error("ShortHelp is empty — the persistent footer would render nothing") + } +} + +// 🔴 EVERY BINDING CARRIES HELP TEXT. A binding present in FullHelp with an +// EMPTY description renders as a blank cell, which is the same invisibility the +// ledger exists to prevent, one layer down. +func TestEveryBindingCarriesNonEmptyHelpText(t *testing.T) { + for _, b := range Dispatch() { + h := b.Binding.Help() + if h.Key == "" || h.Desc == "" { + t.Errorf("%s: help = {Key:%q Desc:%q}, both must be non-empty", + b.Action, h.Key, h.Desc) + } + if len(b.Binding.Keys()) == 0 { + t.Errorf("%s: binding has NO keys", b.Action) + } + } +} + +// 🔴 NO TWO ACTIONS MAY CLAIM THE SAME KEY. `Step` walks the table in order and +// takes the first match, so a duplicate makes the second binding permanently +// dead — a key that is in the footer and does nothing. +func TestNoKeyIsClaimedByTwoActions(t *testing.T) { + owner := map[string]Action{} + for _, b := range Dispatch() { + for _, k := range b.Binding.Keys() { + if prev, dup := owner[k]; dup { + t.Errorf("key %q is claimed by both %s and %s; the second is dead", k, prev, b.Action) + } + owner[k] = b.Action + } + } +} + +// 🔴 THE FOOTER IS RENDERED FROM `Keys`, AND THE RENDERED TEXT PROVES IT. +// Asserting that `renderFooter` returns a non-empty string would pass against a +// hand-written literal. This asserts that every ShortHelp binding's own help +// text appears in the rendered footer — i.e. the footer is a FUNCTION of the +// keymap, and changing a binding's help changes the footer. +func TestTheFooterIsGeneratedFromTheKeymap(t *testing.T) { + a := New("gardenersguild", "trowelcast", 1559) + a.Width, a.Height = 140, 40 + got := stripANSI(a.renderFooter()) + for _, b := range Keys.ShortHelp() { + h := b.Help() + if !strings.Contains(got, h.Key) { + t.Errorf("footer does not carry key %q\nfooter: %s", h.Key, got) + } + if !strings.Contains(got, h.Desc) { + t.Errorf("footer does not carry description %q\nfooter: %s", h.Desc, got) + } + } + // POSITIVE CONTROL for the instrument: a string that is NOT in any binding + // must be absent. Without it, a `Contains` that always returned true would + // pass every assertion above. + if strings.Contains(got, "definitely-not-a-binding") { + t.Error("the footer matcher matches text that is in no binding") + } +} + +// Pressing `?` toggles FullHelp, and the toggle is visible in the render. +func TestQuestionMarkTogglesTheFullHelp(t *testing.T) { + a := New("gardenersguild", "trowelcast", 1559) + a.Width, a.Height = 140, 40 + short := stripANSI(a.renderFooter()) + + next, intents := a.Step(keyPress("?")) + if len(intents) != 0 { + t.Errorf("`?` emitted %d intents, want 0 — it is a local toggle", len(intents)) + } + if !next.showFull { + t.Fatal("`?` did not set showFull") + } + full := stripANSI(next.renderFooter()) + if full == short { + t.Error("FullHelp renders identically to ShortHelp — the toggle is inert") + } + // The expansion must carry a binding that ShortHelp does NOT, or it is not + // an expansion. `prev panel` is in FullHelp only. + if !strings.Contains(full, "prev panel") { + t.Errorf("FullHelp does not carry a FullHelp-only entry\n%s", full) + } + if strings.Contains(short, "prev panel") { + t.Error("fixture is wrong: `prev panel` is in ShortHelp, so it cannot show the expansion") + } +} + +// keyPress builds a v2 key message from the same spelling `key.WithKeys` +// uses, so a test drives the binding table through its real matcher rather +// than through a second translation of its own. +// +// ⚠ v2: the type is `tea.KeyPressMsg` and `tea.KeyMsg` is an INTERFACE; a +// space is spelled "space", not " ". +// +// 🔴 IT PANICS ON AN UNKNOWN SPELLING RATHER THAN RETURNING A ZERO MESSAGE. +// A zero `KeyPressMsg` matches nothing, so a silent fallback would make every +// assertion about that key pass vacuously — the test would "press" a key that +// does not exist and observe, correctly, that nothing happened. +func keyPress(s string) tea.KeyPressMsg { + if named, ok := namedKeys[s]; ok { + return tea.KeyPressMsg{Code: named} + } + if s == "shift+tab" { + return tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} + } + if rest, ok := strings.CutPrefix(s, "ctrl+"); ok && len(rest) == 1 { + return tea.KeyPressMsg{Code: rune(rest[0]), Mod: tea.ModCtrl} + } + if r := []rune(s); len(r) == 1 { + return tea.KeyPressMsg{Code: r[0], Text: s} + } + panic("keyPress: unhandled spelling " + s) +} + +var namedKeys = map[string]rune{ + "tab": tea.KeyTab, + "esc": tea.KeyEscape, + "up": tea.KeyUp, + "down": tea.KeyDown, + "pgup": tea.KeyPgUp, + "pgdown": tea.KeyPgDown, + "home": tea.KeyHome, + "end": tea.KeyEnd, +} + +// 🔴 EVERY SPELLING IN THE DISPATCH TABLE MUST BE BUILDABLE BY `keyPress`, OR +// THE WALK IN `intents_test.go` SILENTLY SKIPS IT. This is the instrument +// check for that walk: without it, adding a binding with a spelling the helper +// cannot build would quietly remove that key from every test that iterates the +// table, and the suite would stay green over a key nobody exercises. +func TestKeyPressCanBuildEverySpellingInTheDispatchTable(t *testing.T) { + built := 0 + for _, b := range Dispatch() { + for _, k := range b.Binding.Keys() { + msg := keyPress(k) // panics on an unknown spelling + // And it must MATCH the binding it came from, or the helper builds + // a well-formed message for the wrong key. + if !key.Matches(msg, b.Binding) { + t.Errorf("keyPress(%q) does not match binding %s", k, b.Action) + } + built++ + } + } + if built == 0 { + t.Fatal("the dispatch table is empty — this check observed nothing") + } + t.Logf("built and matched %d key spellings", built) +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/movement_test.go b/nix/pkgs/tools/mention-review/src/internal/ui/movement_test.go new file mode 100644 index 000000000..4b7350ea9 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/movement_test.go @@ -0,0 +1,274 @@ +package ui + +import ( + "fmt" + "strings" + "testing" +) + +// 🔴 WHY THIS FILE EXISTS — FIVE ACTIONS WERE NEVER PRESSED BY ANY TEST. +// +// `keys_test.go` walks `Dispatch()` and compares it to `FullHelp()`/`ShortHelp()` +// — and both of those are built from the SAME literal, so that ledger asserts +// the keymap agrees with ITSELF. It says nothing about where the cursor lands. +// MEASURED at 4a6c6f88: inverting all four arms of `moveIn`'s movement switch +// +// ActPageUp *cur -= page -> *cur += page +// ActPageDown *cur += page -> *cur -= page +// ActTop *cur = 0 -> *cur = n - 1 +// ActBottom *cur = n - 1 -> *cur = 0 +// +// left ALL FIVE Go packages green. `ActNextFile` was equally unpressed. The +// suite is not uniformly weak — mutating `udiff.go`'s `h.LineIndex > from` to +// `>= from` kills three tests in two packages — the hole was exactly these five. +// +// 🔴 EVERY EXPECTATION HERE IS A LITERAL READ OFF THE SPEC, NOT OFF `moveIn`. +// The viewport height is PINNED BY THE TEST rather than taken from the layout, +// so "half a page" is 10 because the test made the page 20 — the help text's own +// words (`C-u` "half page up") are the contract, and a `moveIn` that computed a +// different number would have to disagree with that sentence to pass. +// +// ⚠ THE SECOND MOVEMENT SWITCH IS COVERED TOO. `move()` has a second, entirely +// separate movement `switch` for the Overview panel's body viewport +// (`HalfPageUp`/`HalfPageDown`/`GotoTop`/`GotoBottom`), which no mutation run +// had ever touched. `TestTheOverviewBodyPagesAndJumpsToItsEnds` presses the same +// five keys there. + +// --- fixture pins ------------------------------------------------------------- + +// bigDiffLines is what `bigApp(t, 200)` parses to: 200 body lines, one hunk +// header, one file header. PINNED as a literal so a fixture that silently +// changed size cannot quietly move every expectation below with it. +const bigDiffLines = 202 + +// pagedDiff is a Diff-panel App whose page size the TEST owns. +// +// 🔴 `a.vp.SetHeight(20)` is the whole point: `page` is `panelBodyHeight()/2`, +// and a test that read the page size out of the live layout would be deriving +// its expectation from the code it is testing. Nothing on a movement path calls +// `relayout()`, so this height survives every `Step` below. +func pagedDiff(t *testing.T) App { + t.Helper() + a := bigApp(t, 200) + if got := len(a.Diff.Lines); got != bigDiffLines { + t.Fatalf("fixture drifted: the big diff parsed to %d lines, want %d", got, bigDiffLines) + } + if a.diffCur != 0 { + t.Fatalf("a freshly loaded diff starts at line %d, want 0", a.diffCur) + } + a.Focus = PanelDiff + a.vp.SetHeight(20) + if got := a.panelBodyHeight(); got != 20 { + t.Fatalf("the test failed to pin the page: panelBodyHeight() = %d, want 20", got) + } + return a +} + +// --- ctrl+d / ctrl+u ---------------------------------------------------------- + +// A half page of a 20-line panel is 10 lines, and `ctrl+u` undoes `ctrl+d`. +// +// 🔴 THE SECOND PRESS IS NOT REDUNDANT. A single press from 0 cannot tell a +// half-page step from any other single jump; two presses pin the STEP, and the +// return journey pins the SIGN of each arm independently. +func TestPageDownAndPageUpMoveByHalfAPageInOppositeDirections(t *testing.T) { + a := pagedDiff(t) + + down1, intents := a.Step(keyPress("ctrl+d")) + if len(intents) != 0 { + t.Errorf("ctrl+d emitted %v — moving a cursor is local", intents) + } + if down1.diffCur != 10 { + t.Errorf("one ctrl+d from the top -> diffCur = %d, want 10", down1.diffCur) + } + + down2, _ := down1.Step(keyPress("ctrl+d")) + if down2.diffCur != 20 { + t.Errorf("two ctrl+d from the top -> diffCur = %d, want 20", down2.diffCur) + } + + up1, intents := down2.Step(keyPress("ctrl+u")) + if len(intents) != 0 { + t.Errorf("ctrl+u emitted %v — moving a cursor is local", intents) + } + if up1.diffCur != 10 { + t.Errorf("ctrl+u from line 20 -> diffCur = %d, want 10", up1.diffCur) + } + + up2, _ := up1.Step(keyPress("ctrl+u")) + if up2.diffCur != 0 { + t.Errorf("a second ctrl+u -> diffCur = %d, want 0", up2.diffCur) + } +} + +// 🔴 THE BOUNDARIES ARE WHERE AN INVERTED ARM IS LOUDEST. At the top of the +// buffer a correct `ctrl+u` is INERT (clamped), while an arm that added instead +// of subtracting would move ten lines DOWN and still look like "a page". +func TestPageUpAtTheTopAndPageDownAtTheBottomAreInert(t *testing.T) { + a := pagedDiff(t) + + top, _ := a.Step(keyPress("ctrl+u")) + if top.diffCur != 0 { + t.Errorf("ctrl+u at the top of the buffer -> diffCur = %d, want 0", top.diffCur) + } + + // Placed on the last line WITHOUT pressing any of the keys under test, so + // this case cannot be rescued by a different arm being right. + a.diffCur = bigDiffLines - 1 + a.syncDiffViewport() + bottom, _ := a.Step(keyPress("ctrl+d")) + if bottom.diffCur != bigDiffLines-1 { + t.Errorf("ctrl+d on the last line -> diffCur = %d, want %d", + bottom.diffCur, bigDiffLines-1) + } +} + +// --- g / G -------------------------------------------------------------------- + +// `g` is the FIRST line and `G` is the LAST one. Started from the middle so +// neither expectation is already true when the key is pressed. +func TestTopAndBottomJumpToTheEndsOfTheDiff(t *testing.T) { + a := pagedDiff(t) + a.diffCur = 100 + a.syncDiffViewport() + + top, intents := a.Step(keyPress("g")) + if len(intents) != 0 { + t.Errorf("g emitted %v — moving a cursor is local", intents) + } + if top.diffCur != 0 { + t.Errorf("g from line 100 -> diffCur = %d, want 0", top.diffCur) + } + + bottom, intents := a.Step(keyPress("G")) + if len(intents) != 0 { + t.Errorf("G emitted %v — moving a cursor is local", intents) + } + if bottom.diffCur != bigDiffLines-1 { + t.Errorf("G from line 100 -> diffCur = %d, want %d", bottom.diffCur, bigDiffLines-1) + } + + // And the pair composes: G then g is back at the top, g then G at the end. + if back, _ := bottom.Step(keyPress("g")); back.diffCur != 0 { + t.Errorf("G then g -> diffCur = %d, want 0", back.diffCur) + } + if fwd, _ := top.Step(keyPress("G")); fwd.diffCur != bigDiffLines-1 { + t.Errorf("g then G -> diffCur = %d, want %d", fwd.diffCur, bigDiffLines-1) + } +} + +// --- } ------------------------------------------------------------------------ + +// `}` lands on the first line of the NEXT file, and is inert on the last file. +// +// ⚠ THE FIXTURE PIN IS LOAD-BEARING: the two-file fixture parses to 9 lines with +// the second file's header at index 4, so `4` below is a position in a buffer +// this test has just measured, not a number copied out of `FileStart`. +func TestNextFileJumpsToTheStartOfTheFollowingFile(t *testing.T) { + a := ready(t) + a.Focus = PanelDiff + if got := len(a.Diff.Files); got != 2 { + t.Fatalf("fixture drifted: %d files, want 2", got) + } + if got := len(a.Diff.Lines); got != 9 { + t.Fatalf("fixture drifted: %d diff lines, want 9", got) + } + if got := a.Diff.FileStart(1); got != 4 { + t.Fatalf("fixture drifted: file 1 starts at line %d, want 4", got) + } + + next, intents := a.Step(keyPress("}")) + if len(intents) != 0 { + t.Errorf("} emitted %v — navigation is local", intents) + } + if next.diffCur != 4 { + t.Errorf("} from the first file -> diffCur = %d, want 4", next.diffCur) + } + // The Files panel follows the diff cursor, as `{` already asserts in the + // other direction. + if next.fileCur != 1 { + t.Errorf("} left fileCur = %d, want 1", next.fileCur) + } + + // On the LAST file `}` is inert rather than wrapping or running off the end. + last, _ := next.Step(keyPress("}")) + if last.diffCur != 4 { + t.Errorf("} on the last file moved the cursor to %d, want it to stay at 4", last.diffCur) + } +} + +// --- the SECOND movement switch: the Overview body viewport -------------------- + +// longIssue is an issue card whose body is longer than the viewport, so its +// scroll offset can actually move. 100 short lines in an 80-column, 10-row +// viewport: no soft wrap, so the offsets below are line counts. +func longIssue(t *testing.T) App { + t.Helper() + snap := fixtureIssue() + var b strings.Builder + for i := 0; i < 100; i++ { + fmt.Fprintf(&b, "line %03d\n", i) + } + snap.Body = strings.TrimSuffix(b.String(), "\n") + + a := New(fxOwner, fxName, fxNum) + a.Width, a.Height = 140, 40 + a, _ = a.Step(PRLoaded{Snap: snap}) + a.Focus = PanelOverview + a.body.SetWidth(80) + a.body.SetHeight(10) + a.body.GotoTop() + + if got := a.body.TotalLineCount(); got != 100 { + t.Fatalf("the issue body wrapped: %d viewport lines, want 100", got) + } + if got := a.body.YOffset(); got != 0 { + t.Fatalf("the body did not start at the top: YOffset = %d", got) + } + return a +} + +// 🔴 THE OVERVIEW PANEL HAS ITS OWN MOVEMENT SWITCH, and it was as untested as +// the diff one. Same five keys, same contract, a different cursor: the body +// viewport's scroll offset. +func TestTheOverviewBodyPagesAndJumpsToItsEnds(t *testing.T) { + a := longIssue(t) + + down1, intents := a.Step(keyPress("ctrl+d")) + if len(intents) != 0 { + t.Errorf("ctrl+d on the Overview emitted %v", intents) + } + if got := down1.body.YOffset(); got != 5 { + t.Errorf("one ctrl+d -> YOffset = %d, want 5 (half of a 10-row viewport)", got) + } + down2, _ := down1.Step(keyPress("ctrl+d")) + if got := down2.body.YOffset(); got != 10 { + t.Errorf("two ctrl+d -> YOffset = %d, want 10", got) + } + up, _ := down2.Step(keyPress("ctrl+u")) + if got := up.body.YOffset(); got != 5 { + t.Errorf("ctrl+u from offset 10 -> YOffset = %d, want 5", got) + } + + // `ctrl+u` at the top is inert — the mirror of the diff-panel boundary. + if got, _ := a.Step(keyPress("ctrl+u")); got.body.YOffset() != 0 { + t.Errorf("ctrl+u at the top -> YOffset = %d, want 0", got.body.YOffset()) + } + + // 100 lines in a 10-row viewport bottoms out at offset 90. + bottom, intents := down1.Step(keyPress("G")) + if len(intents) != 0 { + t.Errorf("G on the Overview emitted %v", intents) + } + if got := bottom.body.YOffset(); got != 90 { + t.Errorf("G -> YOffset = %d, want 90", got) + } + top, _ := bottom.Step(keyPress("g")) + if got := top.body.YOffset(); got != 0 { + t.Errorf("g from the bottom -> YOffset = %d, want 0", got) + } + // And `ctrl+d` at the bottom is inert. + if got, _ := bottom.Step(keyPress("ctrl+d")); got.body.YOffset() != 90 { + t.Errorf("ctrl+d at the bottom -> YOffset = %d, want 90", got.body.YOffset()) + } +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/panels.go b/nix/pkgs/tools/mention-review/src/internal/ui/panels.go new file mode 100644 index 000000000..ff6cb223c --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/panels.go @@ -0,0 +1,437 @@ +package ui + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + tea "charm.land/bubbletea/v2" + + "github.com/innovation-upstream/devrc/mention-review/internal/ghapi" + "github.com/innovation-upstream/devrc/mention-review/internal/udiff" +) + +// Layout constants. The left column is fixed; the diff takes the rest. +const ( + leftColWidth = 34 + minWidth = 60 + minHeight = 12 +) + +// View renders the whole screen. +// +// 🔴 v2: View RETURNS A tea.View STRUCT, NOT A STRING — and that struct is also +// where AltScreen, Cursor and MouseMode now live, because `tea.WithAltScreen()` +// and the terminal-feature program options are GONE. Every v1 tutorial opens +// with `tea.WithAltScreen()`; it does not compile here. +func (a App) View() tea.View { + v := tea.NewView(a.render()) + v.AltScreen = true + v.BackgroundColor = bg0 + return v +} + +func (a App) render() string { + if a.Width < minWidth || a.Height < minHeight { + // A window too small to lay out gets a WORD, not a mangled frame. + return styWarn.Render(fmt.Sprintf( + "WINDOW TOO SMALL — %dx%d, need at least %dx%d", + a.Width, a.Height, minWidth, minHeight)) + } + footer := a.renderFooter() + bodyH := a.Height - lipgloss.Height(footer) + + var body string + switch { + case a.Load == LoadFailed: + body = a.renderCard(bodyH, a.errorCardTitle(), a.body.View()) + case a.Load == LoadLoading: + body = a.renderCard(bodyH, "LOADING — "+a.Repo()+"#"+itoa(a.Num), "") + case a.Snap != nil && a.Snap.Kind == ghapi.KindIssue: + body = a.renderIssueCard(bodyH) + default: + body = a.renderPanels(bodyH) + } + return lipgloss.JoinVertical(lipgloss.Left, body, footer) +} + +// renderFooter renders the GENERATED help. +// +// 🔴 IT IS `help.Model.View(Keys)` AND NOTHING ELSE. There is no string literal +// here listing keys. The footer reads the very same `key.Binding` values +// `Dispatch()` matches on, so the two cannot disagree — and `keys_test.go` +// asserts the two SETS are equal in both directions so that stays true when +// somebody adds a binding. +func (a App) renderFooter() string { + line := a.help.View(Keys) + if a.Snap != nil && a.Snap.ViewerLogin != "" { + // 🔴 THE AUTHENTICATED LOGIN IS ON SCREEN AT ALL TIMES (§10.2). + // cli/cli#14370: the OS keyring is not partitioned by account, so the + // resolved token can belong to a DIFFERENT account than the config's + // active one — and this host's hosts.yml carries two github.com users. + // It is a curiosity for a read-only tool and the difference between + // approving as yourself and approving as somebody else for one that can + // merge. Putting it on screen now means it is already there when the + // write actions land. + line = lipgloss.JoinHorizontal(lipgloss.Top, + line, styDim.Render(" · as "), styAccent.Render(a.Snap.ViewerLogin)) + } + return lipgloss.NewStyle().Padding(0, 1).Render(line) +} + +func (a App) renderPanels(h int) string { + leftW := leftColWidth + if a.Width < leftColWidth*2 { + leftW = a.Width / 3 + } + rightW := a.Width - leftW + + // Three stacked boxes on the left; the tallest gets the remainder. + ovH := max(7, h/3) + cmH := max(4, (h-ovH)/2) + flH := h - ovH - cmH + + left := lipgloss.JoinVertical(lipgloss.Left, + a.box(PanelOverview, leftW, ovH, a.overviewBody(leftW-4, ovH-2)), + a.box(PanelCommits, leftW, cmH, a.commitsBody(leftW-4, cmH-2)), + a.box(PanelFiles, leftW, flH, a.filesBody(leftW-4, flH-2)), + ) + right := a.box(PanelDiff, rightW, h, a.diffBody()) + return lipgloss.JoinHorizontal(lipgloss.Top, left, right) +} + +// box draws one panel with its border and title. The focused panel's border is +// coloured AND its title carries a marker, so focus survives colour removal. +func (a App) box(p Panel, w, h int, body string) string { + sty := borderBlur + title := styDim.Render(" " + p.Title() + " ") + if a.Focus == p { + sty = borderFocus + // 🔴 `>` IS THE FOCUS CARRIER, THE COLOUR IS DECORATION. Focus is a + // meaning-bearing state like any other, so it is spelled. + title = styTitle.Render(" > " + p.Title() + " ") + } + inner := w - 2 + if inner < 1 { + inner = 1 + } + head := title + if a.Focus == PanelDiff && p == PanelDiff && a.currentFilePath() != "" { + head = lipgloss.JoinHorizontal(lipgloss.Top, title, styDim.Render(a.currentFilePath())) + } + content := lipgloss.JoinVertical(lipgloss.Left, head, body) + return sty.Width(inner).Height(max(1, h-2)).Render(content) +} + +func (a App) currentFilePath() string { + if a.Diff == nil || len(a.Diff.Files) == 0 { + return "" + } + i := a.Diff.FileAt(a.diffCur) + if i < 0 || i >= len(a.Diff.Files) { + return "" + } + return a.Diff.Files[i].Path +} + +// --- panel bodies ----------------------------------------------------------- + +func (a App) overviewBody(w, h int) string { + if a.Snap == nil { + return "" + } + s := a.Snap + rows := []string{ + styTitle.Render("#" + itoa(s.Num) + " " + truncate(s.Title, w-8)), + styDim.Render(s.Author + " ") + styGood.Render("+"+itoa(s.Additions)) + + styDim.Render(" / ") + styBad.Render("-"+itoa(s.Deletions)), + "", + kv("STATE ", PRStateWord(s.State, s.IsDraft)), + kv("REVIEW", ReviewWord(s.ReviewDecision)), + kv("MERGE ", MergeWord(s.Mergeable, s.MergeStateStatus, s.Merged)), + kv("CHECKS", ChecksWord(s.Checks)), + kv("THREAD", ThreadsWord(s.Threads)), + styDim.Render("VIEWER as ") + styAccent.Render(s.ViewerLogin), + styDim.Render(s.BaseRef + " <- " + s.HeadRef), + } + return strings.Join(clip(rows, h), "\n") +} + +// diffBody is the right-hand pane. +// +// 🔴 A DIFF THAT FAILED SAYS SO *HERE*, NOT IN THE OVERVIEW. The Overview is +// height-clipped, so an error appended to its tail is the row that gets cut — +// measured: the message was off-screen at the default 100x30 and the test that +// asserts it is visible caught it. The Diff panel is the pane that has nothing +// to show, so it is the pane that explains why. +func (a App) diffBody() string { + if a.Diff == nil { + if a.Err != nil { + return lipgloss.JoinVertical(lipgloss.Left, + styBad.Render("DIFF UNAVAILABLE"), + "", + styDim.Render(a.Err.Error()), + "", + styDim.Render("`r` retries · `o` opens it in the browser")) + } + return styDim.Render("LOADING DIFF") + } + if a.Diff.Truncated { + return lipgloss.JoinVertical(lipgloss.Left, + styWarn.Render("TRUNCATED — more files than one page carries"), + a.vp.View()) + } + return a.vp.View() +} + +func kv(label string, w StateWord) string { + return styDim.Render(label+": ") + w.Render() +} + +func (a App) commitsBody(w, h int) string { + if a.Snap == nil || len(a.Snap.Commits) == 0 { + return styDim.Render("NO COMMITS") + } + var rows []string + for i, c := range a.Snap.Commits { + line := fmt.Sprintf("%s %s", c.Abbrev, truncate(c.Headline, max(1, w-10))) + if i == a.commitCur && a.Focus == PanelCommits { + rows = append(rows, styCursor.Render(line)) + } else { + rows = append(rows, styText.Render(line)) + } + } + if a.Snap.CommitsTruncated { + rows = append(rows, styWarn.Render("TRUNCATED — more commits than one page")) + } + return strings.Join(window(rows, a.commitCur, h), "\n") +} + +func (a App) filesBody(w, h int) string { + if a.Snap == nil || len(a.Snap.Files) == 0 { + return styDim.Render("NO FILES") + } + var rows []string + for i, f := range a.Snap.Files { + mark := FileWord(f.ChangeType) + count := fmt.Sprintf("+%d -%d", f.Additions, f.Deletions) + name := truncate(f.Path, max(1, w-len(count)-3)) + line := mark.Render() + " " + name + " " + styDim.Render(count) + if i == a.fileCur && a.Focus == PanelFiles { + line = styCursor.Render(mark.Word + " " + name + " " + count) + } + rows = append(rows, line) + } + if a.Snap.FilesTruncated { + rows = append(rows, styWarn.Render("TRUNCATED — more files than one page")) + } + return strings.Join(window(rows, a.fileCur, h), "\n") +} + +// --- the diff viewport ------------------------------------------------------ + +// 🔴 THE BUFFER IS SET ONCE, PLAIN; ONLY THE *VISIBLE* ROWS ARE STYLED. +// +// This is §6.2 hazard 2, and the first implementation of this file got it wrong +// in exactly the way the hazard describes — while carrying a comment claiming +// the opposite. It rendered every line through a lipgloss Style on every cursor +// move, and the Phase-1 measurement on the real panel caught it: +// +// diff lines CPU per frame 60 fps budget +// 200 4.1 ms 24.6 % +// 4000 20.5 ms 123.1 % <- misses the budget +// 10000 46.1 ms 276.3 % <- misses it badly +// +// Phase 0's bare viewport was FLAT across the same buffer sizes, so this defect +// was structurally invisible there: the bare probe never styled anything. That +// is precisely why the proposal made this a Phase-1 finding rather than a +// Phase-3 one, and it is a worked example of "verified in isolation" — both +// halves measured clean, the seam between them broken. +// +// The fix uses the viewport's own `StyleLineFunc`, which `visibleLines()` calls +// on the SLICE it is about to render, with an offset — so the styling work is +// bounded by the pane height (~36 rows) instead of the buffer (up to 10,000). + +// rebuildDiffContent sets the plain text. Called when the DIFF changes or the +// pane is resized — never on a cursor move. +// +// 🔴 `SetContentLines`, NOT `JoinVertical` + `SetContent`. The join-then- +// resplit round trip is a measured 8% of total CPU in a comparable case study, +// and switching that one path took it from 2.19 s to ~270 ms. It is a Bubble +// Tea v2-era addition — a small, concrete reason the version choice matters. +func (a *App) rebuildDiffContent() { + if a.Diff == nil { + a.vp.SetContentLines(nil) + return + } + lines := make([]string, len(a.Diff.Lines)) + for i, ln := range a.Diff.Lines { + lines[i] = plainDiffLine(ln) + } + a.vp.SetContentLines(lines) +} + +// syncDiffViewport re-points the style function at the current cursor and +// scrolls to it. 🔴 O(1) IN BUFFER SIZE — it allocates one closure. +func (a *App) syncDiffViewport() { + d, cur := a.Diff, a.diffCur + a.vp.StyleLineFunc = func(i int) lipgloss.Style { + if i == cur { + return styCursor + } + if d == nil || i < 0 || i >= len(d.Lines) { + return styCtx + } + return diffLineStyle(d.Lines[i].Op) + } + if d != nil { + a.vp.EnsureVisible(cur, 0, 0) + } +} + +// plainDiffLine is the row's TEXT, with no colour at all. +// +// 🔴 THE MARKER CHARACTER IS THE CARRIER AND IT IS PART OF THE TEXT. `+`, `-` +// and a leading space say what the line does, so stripping every colour leaves +// a readable unified diff — exactly the property the operator's font constraint +// demands, and the same property `words_test.go` asserts for every other state. +func plainDiffLine(ln udiff.Line) string { + switch ln.Op { + case udiff.OpAdd: + return "+" + ln.Text + case udiff.OpDelete: + return "-" + ln.Text + case udiff.OpHunk, udiff.OpMeta: + return ln.Text + } + return " " + ln.Text +} + +// diffLineStyle is lazygit's three colours and nothing else (§6.3) — no lexer, +// no syntax highlighting. +func diffLineStyle(op udiff.Op) lipgloss.Style { + switch op { + case udiff.OpAdd: + return styAdd + case udiff.OpDelete: + return styDel + case udiff.OpHunk: + return styHunk + case udiff.OpMeta: + return styMeta + } + return styCtx +} + +// --- cards ------------------------------------------------------------------ + +// renderIssueCard is §4's one-screen card. +// +// 🔴 READ-ONLY AND TERMINAL. The line this draws is explicit: no comment +// posting, no labels, no close/reopen, no threading, no navigation to related +// issues. The body is already in the GraphQL response, so rendering it costs +// nothing extra and there is no second round trip anywhere in the chain. +func (a App) renderIssueCard(h int) string { + s := a.Snap + head := lipgloss.JoinVertical(lipgloss.Left, + styWarn.Render("ISSUE — not a pull request, so there is nothing to review"), + "", + styDim.Render(s.Repo+"#"+itoa(s.Num)+" ")+PRStateWord(s.State, false).Render(), + styTitle.Render(s.Title), + styDim.Render("opened by "+s.Author), + styDim.Render("as "+s.ViewerLogin), + "", + ) + return a.renderCard(h, "", lipgloss.JoinVertical(lipgloss.Left, head, a.body.View())) +} + +func (a App) errorCardTitle() string { + st := ghapi.AuthOther + var ae *ghapi.APIError + if errorsAs(a.Err, &ae) { + st = ae.State + } + w := StateWord{st.Word(), styBad} + if st == ghapi.AuthRateLimited { + w.Style = styWarn + } + return w.Render() + styDim.Render(" — "+st.Hint()) +} + +func (a App) renderCard(h int, title, body string) string { + content := body + if title != "" { + content = lipgloss.JoinVertical(lipgloss.Left, title, "", body) + } + return borderFocus. + Width(max(1, a.Width-2)). + Height(max(1, h-2)). + Render(content) +} + +// --- layout helpers --------------------------------------------------------- + +func (a *App) relayout() { + footerH := lipgloss.Height(a.renderFooter()) + bodyH := max(minHeight, a.Height-footerH) + + leftW := leftColWidth + if a.Width < leftColWidth*2 { + leftW = a.Width / 3 + } + a.vp.SetWidth(max(10, a.Width-leftW-4)) + a.vp.SetHeight(max(3, bodyH-3)) + + a.body.SetWidth(max(10, a.Width-6)) + a.body.SetHeight(max(3, bodyH-10)) + a.rebuildDiffContent() + a.syncDiffViewport() +} + +func (a *App) setBody(s string) { + a.body.SetContent(s) + a.body.GotoTop() +} + +func (a App) panelBodyHeight() int { return max(1, a.vp.Height()) } + +func truncate(s string, w int) string { + if w <= 0 { + return "" + } + r := []rune(s) + if len(r) <= w { + return s + } + if w <= 1 { + return string(r[:w]) + } + return string(r[:w-1]) + "…" +} + +// clip takes the first h rows. +func clip(rows []string, h int) []string { + if h <= 0 || len(rows) <= h { + return rows + } + return rows[:h] +} + +// window returns h rows centred enough to keep `cur` visible. 🔴 It slices +// rather than styling everything and then hiding most of it — same reason as +// syncDiffViewport. +func window(rows []string, cur, h int) []string { + if h <= 0 || len(rows) <= h { + return rows + } + start := cur - h/2 + start = clamp(start, 0, len(rows)-h) + return rows[start : start+h] +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/run.go b/nix/pkgs/tools/mention-review/src/internal/ui/run.go new file mode 100644 index 000000000..e7e81dfe0 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/run.go @@ -0,0 +1,116 @@ +package ui + +import ( + "context" + "os/exec" + + tea "charm.land/bubbletea/v2" + + "github.com/innovation-upstream/devrc/mention-review/internal/ghapi" + "github.com/innovation-upstream/devrc/mention-review/internal/udiff" +) + +// 🔴 THE ONLY PLACE I/O IS CONSTRUCTED (§3.3). +// +// `Step` returns intents; this file turns them into `tea.Cmd`s. It is small on +// purpose: everything here is untestable-by-assertion (a `tea.Cmd` is an opaque +// `func() tea.Msg`), so the less of it there is, the more of the program is +// covered by the pure tests. + +// Runner is the effect surface. An interface rather than a concrete client so +// the one end-to-end test can substitute a fake without a live network. +type Runner interface { + FetchPR(ctx context.Context, owner, name string, num int) (*ghapi.Snapshot, error) + FetchDiff(ctx context.Context, owner, name string, num int) (*udiff.Diff, error) + OpenBrowser(url string) error +} + +// Run converts ONE intent into a command. +// +// 🔴 A DEFAULT CASE THAT PANICS, NOT ONE THAT SILENTLY RETURNS nil. A new +// intent that nobody wired would otherwise be a keypress that does nothing, +// forever, with no error anywhere — the exact silent-zero shape this repo keeps +// getting bitten by. `intents_test.go`'s +// `TestEveryRegisteredIntentIsHandledByRun` asserts every registered intent is +// handled — there is no `run_test.go`, and this comment named one for a while — +// so the panic is unreachable in a passing build and is the backstop for a +// build that is not. +func Run(i Intent, r Runner) tea.Cmd { + switch v := i.(type) { + case FetchPR: + return func() tea.Msg { + s, err := r.FetchPR(context.Background(), v.Owner, v.Name, v.Num) + return PRLoaded{Snap: s, Err: err} + } + case FetchDiff: + return func() tea.Msg { + d, err := r.FetchDiff(context.Background(), v.Owner, v.Name, v.Num) + return DiffLoaded{Diff: d, Err: err} + } + case OpenBrowser: + return func() tea.Msg { + // Fire and forget. §6.1 records the failure mode that matters here: + // a window that flashes and vanishes teaches the operator nothing, + // so a failed browser launch must never take the TUI down with it. + _ = r.OpenBrowser(v.URL) + return nil + } + } + panic("ui.Run: unhandled intent " + i.intentName()) +} + +// RunAll maps a slice of intents. +func RunAll(intents []Intent, r Runner) []tea.Cmd { + if len(intents) == 0 { + return nil + } + cmds := make([]tea.Cmd, 0, len(intents)) + for _, i := range intents { + cmds = append(cmds, Run(i, r)) + } + return cmds +} + +// --- the live runner -------------------------------------------------------- + +// LiveRunner performs the real effects. +type LiveRunner struct{ C *ghapi.Client } + +func (l LiveRunner) FetchPR(ctx context.Context, owner, name string, num int) (*ghapi.Snapshot, error) { + return l.C.Fetch(ctx, owner, name, num) +} + +func (l LiveRunner) FetchDiff(ctx context.Context, owner, name string, num int) (*udiff.Diff, error) { + files, truncated, err := l.C.FetchFiles(ctx, owner, name, num) + if err != nil { + return nil, err + } + in := make([]udiff.FileInput, 0, len(files)) + for _, f := range files { + in = append(in, udiff.FileInput{ + Path: f.Path, + PrevPath: f.PreviousPath, + ChangeType: f.ChangeType, + Additions: f.Additions, + Deletions: f.Deletions, + Patch: f.Patch, + }) + } + d, err := udiff.Parse(in) + if err != nil { + return nil, err + } + d.Truncated = truncated + return d, nil +} + +// OpenBrowser shells out to xdg-open. +// +// 🔴 PINNED BY THE WRAPPER'S PATH, NOT INHERITED. The whole call chain starts +// in an Alacritty hint spawned with the DISPLAY MANAGER's environment, and +// `~/.nix-profile` is blanked for ~30 s during every home-manager switch. The +// Alacritty wrapper's `lib.makeBinPath` already carries `pkgs.xdg-utils` for +// exactly this reason, and the packaging puts it in `runtimeInputs` too. +func (LiveRunner) OpenBrowser(url string) error { + return exec.Command("xdg-open", url).Start() +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/scroll_test.go b/nix/pkgs/tools/mention-review/src/internal/ui/scroll_test.go new file mode 100644 index 000000000..51edf999a --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/scroll_test.go @@ -0,0 +1,290 @@ +package ui + +import ( + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/innovation-upstream/devrc/mention-review/internal/udiff" +) + +// 🔴 THE PHASE-1 SCROLL RE-MEASUREMENT, ON THE *REAL* PANEL. +// +// Phase 0 measured a bare `viewport`. This measures the whole four-panel screen +// — Overview + Commits + Files + the generated footer + the diff viewport — at +// the measured p99 (~4,000 diff lines) and at the observed max (~10,000). +// +// It exists because bubbletea#1724 reports the v2 "cursed renderer" at +// ~300–800 µs per scroll frame against v1's ~50–100 µs, and names log viewers +// and code browsers as the affected shape. A scrolling diff viewer is exactly +// that workload. +// +// 🔴 IT OPENS NO WINDOW. Output goes to an in-memory writer; input is a pipe. +// +// TWO NUMBERS, BECAUSE THEY ANSWER DIFFERENT QUESTIONS: +// - `View()` cost — how expensive a frame is. Comparable to #1724. +// - CPU per frame — the same question end-to-end, through the real renderer. +// WALL latency is NOT a measure of render cost here: Bubble Tea's frame clock +// is 60 fps by default (120 max), so a healthy program is clock-bound and its +// wall latency is 1/fps whatever the buffer holds. Reporting wall time alone +// would hide a renderer that had got 10x slower but still fitted in the budget. + +// syntheticDiffLines builds n lines of realistic-looking diff. +// +// 🔴 SYNTHETIC. This repository is PUBLIC; a captured diff must not land in it. +func syntheticDiffFiles(totalLines int) []udiff.FileInput { + const perFile = 400 + var files []udiff.FileInput + remaining := totalLines + for i := 0; remaining > 0; i++ { + n := perFile + if n > remaining { + n = remaining + } + remaining -= n + var b strings.Builder + // One hunk per file, with n body lines: alternating adds, deletes and + // context. The header's counts are DERIVED from what is written, so the + // fixture cannot drift out of validity. + var body strings.Builder + oldN, newN := 0, 0 + for j := 0; j < n; j++ { + line := fmt.Sprintf(" ctx := build(req, %d) // a moderately long trailing comment %d", j, i) + switch j % 4 { + case 0: + body.WriteString("+" + line + "\n") + newN++ + case 1: + body.WriteString("-" + line + "\n") + oldN++ + default: + body.WriteString(" " + line + "\n") + oldN++ + newN++ + } + } + fmt.Fprintf(&b, "@@ -1,%d +1,%d @@ func handler%d(req *Request) error {\n", oldN, newN, i) + b.WriteString(body.String()) + files = append(files, udiff.FileInput{ + Path: fmt.Sprintf("pkg/generated/module_%03d.go", i), + ChangeType: "MODIFIED", + Additions: newN, + Deletions: oldN, + Patch: b.String(), + }) + } + return files +} + +func bigApp(t testing.TB, lines int) App { + t.Helper() + d, err := udiff.Parse(syntheticDiffFiles(lines)) + if err != nil { + t.Fatal(err) + } + a := New(fxOwner, fxName, fxNum) + a.Width, a.Height = 140, 40 + a, _ = a.Step(PRLoaded{Snap: fixturePR()}) + a, _ = a.Step(DiffLoaded{Diff: d}) + return a +} + +// --- A. frame cost, precise -------------------------------------------------- + +func benchScreen(b *testing.B, lines int) { + a := bigApp(b, lines) + n := len(a.Diff.Lines) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + a.diffCur = i % n + a.syncDiffViewport() + _ = a.render() + } +} + +func BenchmarkFullScreenMedian200(b *testing.B) { benchScreen(b, 200) } +func BenchmarkFullScreenP99_4000(b *testing.B) { benchScreen(b, 4000) } +func BenchmarkFullScreenMax10000(b *testing.B) { benchScreen(b, 10000) } + +// 🔴 THE INSTRUMENT CONTROL FOR THE BENCHMARKS ABOVE. §6.2 hazard 1 says +// `SoftWrap = true` makes `viewport.calculateLine` O(n) where false is O(1). If +// the two are indistinguishable, the benchmark is not reading the buffer and +// its flat numbers mean nothing. +func benchScreenSoftWrap(b *testing.B, lines int) { + a := bigApp(b, lines) + a.vp.SoftWrap = true + n := len(a.Diff.Lines) + b.ResetTimer() + for i := 0; i < b.N; i++ { + a.diffCur = i % n + a.syncDiffViewport() + _ = a.render() + } +} + +func BenchmarkFullScreenSoftWrapP99_4000(b *testing.B) { benchScreenSoftWrap(b, 4000) } +func BenchmarkFullScreenSoftWrapMax10000(b *testing.B) { benchScreenSoftWrap(b, 10000) } + +// --- B. end to end, through the real renderer -------------------------------- + +type countingWriter struct { + mu sync.Mutex + writes int + signal chan struct{} +} + +func (w *countingWriter) Write(p []byte) (int, error) { + w.mu.Lock() + w.writes++ + w.mu.Unlock() + select { + case w.signal <- struct{}{}: + default: + } + return len(p), nil +} + +func (w *countingWriter) count() int { + w.mu.Lock() + defer w.mu.Unlock() + return w.writes +} + +func procCPU() time.Duration { + var ru syscall.Rusage + if err := syscall.Getrusage(syscall.RUSAGE_SELF, &ru); err != nil { + return 0 + } + tv := func(t syscall.Timeval) time.Duration { + return time.Duration(t.Sec)*time.Second + time.Duration(t.Usec)*time.Microsecond + } + return tv(ru.Utime) + tv(ru.Stime) +} + +// TestScrollingTheRealPanelMeetsTheFrameBudget is the Phase-1 measurement. +// +// It FAILS if the per-frame CPU cost exceeds the 60 fps budget, so this is a +// guard as well as a report. The threshold is the frame budget itself rather +// than a number copied off a passing run: a run that cannot produce a frame +// inside 1/60 s is janky by definition, and any tighter bound would be a +// ratchet on this box's speed rather than on the program. +func TestScrollingTheRealPanelMeetsTheFrameBudget(t *testing.T) { + if os.Getenv("DEVRC_SKIP_SCROLL_MEASUREMENT") != "" { + t.Skip("scroll measurement disabled by DEVRC_SKIP_SCROLL_MEASUREMENT") + } + const frameBudget60 = time.Second / 60 // 16.67 ms + + for _, lines := range []int{200, 4000, 10000} { + t.Run("lines="+strconv.Itoa(lines), func(t *testing.T) { + a := bigApp(t, lines) + keys := 120 + if max := len(a.Diff.Lines) - a.vp.Height() - 2; keys > max { + keys = max + } + if keys < 10 { + t.Fatalf("only %d scrollable steps at %d lines", keys, lines) + } + + cw := &countingWriter{signal: make(chan struct{}, 1)} + inR, inW := io.Pipe() + defer inW.Close() + + p := tea.NewProgram(a, + tea.WithInput(inR), + tea.WithOutput(cw), + tea.WithWindowSize(140, 40), + tea.WithoutSignalHandler(), + tea.WithColorProfile(0), + ) + done := make(chan tea.Model, 1) + go func() { + m, err := p.Run() + if err != nil { + t.Error(err) + } + done <- m + }() + + select { + case <-cw.signal: + case <-time.After(5 * time.Second): + t.Fatal("no initial frame — the harness rendered NOTHING") + } + + cpu0 := procCPU() + lat := make([]time.Duration, 0, keys) + for i := 0; i < keys; i++ { + select { + case <-cw.signal: + default: + } + start := time.Now() + p.Send(keyPress("j")) + select { + case <-cw.signal: + lat = append(lat, time.Since(start)) + case <-time.After(3 * time.Second): + // ⚠ THIS IS NOT NECESSARILY A HANG. The v2 renderer only + // writes when the view actually CHANGED, so a scroll step + // past the bottom legitimately produces no frame. `keys` is + // capped to the scrollable range above so that cannot + // happen here — reaching this means something else stalled. + t.Fatalf("step %d produced no frame within 3s", i) + } + } + cpu := procCPU() - cpu0 + perFrame := cpu / time.Duration(len(lat)) + + p.Quit() + final := <-done + fm, _ := final.(App) + + // --- POSITIVE CONTROLS. A zero here is the harness observing + // nothing, which would otherwise read as a very fast pass. + if cw.count() == 0 { + t.Fatal("0 renderer writes — the harness observed NOTHING") + } + if fm.diffCur == 0 { + t.Fatal("the diff cursor never moved — nothing was scrolled") + } + if len(lat) != keys { + t.Fatalf("collected %d latencies, want %d", len(lat), keys) + } + + p50, p99, mx := stats(lat) + t.Logf("lines=%-6d frames=%-4d finalCursor=%-5d", lines, cw.count(), fm.diffCur) + t.Logf(" CPU per frame %v (budget at 60 fps: %v, %.1f%% used)", + perFrame.Round(time.Microsecond), frameBudget60.Round(time.Microsecond), + 100*float64(perFrame)/float64(frameBudget60)) + t.Logf(" key->frame WALL p50=%v p99=%v max=%v (frame-clock bound at 1/60s)", + p50.Round(10*time.Microsecond), p99.Round(10*time.Microsecond), + mx.Round(10*time.Microsecond)) + + // 🔴 THE GUARD. Not a ratchet on a measured number — the 60 fps + // frame budget itself. + if perFrame > frameBudget60 { + t.Errorf("scrolling a %d-line diff costs %v of CPU per frame, "+ + "which does not fit the 60 fps budget of %v — this is the "+ + "Phase-0 kill criterion, re-measured on the real panel", + lines, perFrame, frameBudget60) + } + }) + } +} + +func stats(lat []time.Duration) (p50, p99, mx time.Duration) { + s := append([]time.Duration(nil), lat...) + sort.Slice(s, func(i, j int) bool { return s[i] < s[j] }) + at := func(q float64) time.Duration { return s[int(q*float64(len(s)-1))] } + return at(0.50), at(0.99), s[len(s)-1] +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/theme.go b/nix/pkgs/tools/mention-review/src/internal/ui/theme.go new file mode 100644 index 000000000..43de9751e --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/theme.go @@ -0,0 +1,100 @@ +package ui + +import ( + "image/color" + + "charm.land/lipgloss/v2" +) + +// Gruvbox Dark, matching the Alacritty palette in +// `nix/programs/alacritty/default.nix`. +// +// 🔴 THE HEX VALUES ARE A LEDGER, AND A TEST PINS THEM TWO-WAY AGAINST THAT +// NIX FILE. The whole point of a matching palette is that the review window +// does not look like a different application from the terminal it opened out +// of, and a palette that drifts is a palette that stopped doing its one job. +// `theme_test.go` parses the Nix `colors` block and asserts the two maps are +// EQUAL — so a colour changed on either side fails the suite, rather than being +// noticed on screen months later. +// +// ⚠ IN LIP GLOSS v2 `lipgloss.Color` IS A FUNCTION RETURNING A STDLIB +// `color.Color`, NOT A STRING TYPE — every v1 example spells it as a +// conversion and will not compile. The whole `Renderer` concept is gone too: +// no `NewRenderer`, no `SetDefaultRenderer`, no `SetColorProfile`. That is +// what invalidates the standard golden-file recipe and any palette code copied +// from a v1 tutorial. + +// PaletteHex is the ledger. Keys are `.` exactly as the Nix +// attribute set nests them. +var PaletteHex = map[string]string{ + "primary.background": "#282828", + "primary.foreground": "#ebdbb2", + + "normal.black": "#282828", + "normal.red": "#cc241d", + "normal.green": "#98971a", + "normal.yellow": "#d79921", + "normal.blue": "#458588", + "normal.magenta": "#b16286", + "normal.cyan": "#689d6a", + "normal.white": "#a89984", + + "bright.black": "#928374", + "bright.red": "#fb4934", + "bright.green": "#b8bb26", + "bright.yellow": "#fabd2f", + "bright.blue": "#83a598", + "bright.magenta": "#d3869b", + "bright.cyan": "#8ec07c", + "bright.white": "#ebdbb2", +} + +// c looks a colour up BY LEDGER KEY. 🔴 Nothing below spells a hex literal: +// a second copy of a value is how a value and its guard drift apart, and this +// way the ledger is not merely documentation of the palette — it IS the +// palette, so the test that pins it pins what is actually rendered. +func c(key string) color.Color { return lipgloss.Color(PaletteHex[key]) } + +var ( + bg0 = c("primary.background") + fg0 = c("primary.foreground") + + bBlack = c("bright.black") + bRed = c("bright.red") + bGreen = c("bright.green") + bYellow = c("bright.yellow") + bBlue = c("bright.blue") + bMagenta = c("bright.magenta") +) + +// Styles. 🔴 COLOUR IS DECORATION ON TOP OF A WORD THAT ALREADY SAYS IT, NEVER +// THE CARRIER. The operator's font renders the red/yellow/green severity +// circles as one indistinguishable glyph, so every meaning-bearing state in +// this UI is spelled out — `CHANGES`, `CLEAN`, `1 FAILING`, `M`/`A`/`D` — and +// `words_test.go` renders each one with all colour removed and asserts the +// word survives. +var ( + styDim = lipgloss.NewStyle().Foreground(bBlack) + styText = lipgloss.NewStyle().Foreground(fg0) + styTitle = lipgloss.NewStyle().Foreground(bYellow).Bold(true) + styAccent = lipgloss.NewStyle().Foreground(bBlue) + styGood = lipgloss.NewStyle().Foreground(bGreen) + styWarn = lipgloss.NewStyle().Foreground(bYellow) + styBad = lipgloss.NewStyle().Foreground(bRed) + styMerged = lipgloss.NewStyle().Foreground(bMagenta) + + // Diff rows. lazygit's three colours and nothing else (§6.3). + styAdd = lipgloss.NewStyle().Foreground(bGreen) + styDel = lipgloss.NewStyle().Foreground(bRed) + styCtx = lipgloss.NewStyle().Foreground(fg0) + styHunk = lipgloss.NewStyle().Foreground(bBlue).Bold(true) + styMeta = lipgloss.NewStyle().Foreground(bBlack).Italic(true) + styCursor = lipgloss.NewStyle().Foreground(bg0).Background(bYellow) + + borderFocus = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(bYellow) + borderBlur = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(bBlack) +) diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/theme_test.go b/nix/pkgs/tools/mention-review/src/internal/ui/theme_test.go new file mode 100644 index 000000000..8d05988a1 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/theme_test.go @@ -0,0 +1,185 @@ +package ui + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// 🔴 THE PALETTE IS PINNED TWO-WAY AGAINST THE ALACRITTY CONFIG. +// +// The whole point of a matching palette is that the review window does not look +// like a different application from the terminal it opened out of. A palette +// that drifts is a palette that stopped doing its one job — and drift is +// exactly the kind of thing nobody notices for months. +// +// ⚠ THIS READS A FILE OUTSIDE THE GO MODULE, so it is skipped when it cannot +// find it. 🔴 THE SKIP IS LOUD AND IT COUNTS WHAT IT PARSED: a test that +// silently skips is worse than no test, and a parser that matched zero colours +// would otherwise report "0 mismatches" and read as a pass. + +var alacrittyColor = regexp.MustCompile(`^\s*([a-z_]+)\s*=\s*"(#[0-9a-fA-F]{6})";`) +var alacrittyGroup = regexp.MustCompile(`^\s*([a-z_]+)\s*=\s*\{\s*$`) + +// alacrittyPalette parses the `colors` block out of the Nix file. +// +// It is a small hand parser rather than a Nix evaluation because the test tier +// must not need `nix` on PATH — and because what matters is the literal hex +// strings a human reads in that file. +func alacrittyPalette(t *testing.T, path string) map[string]string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("cannot read the Alacritty config: %v", err) + } + out := map[string]string{} + var group string + inColors := false + depth := 0 + for _, line := range strings.Split(string(raw), "\n") { + trimmed := strings.TrimSpace(line) + if !inColors { + if trimmed == "colors = {" { + inColors = true + } + continue + } + if m := alacrittyGroup.FindStringSubmatch(line); m != nil { + group = m[1] + depth++ + continue + } + if trimmed == "};" { + if depth == 0 { + break // the end of the `colors` block itself + } + depth-- + group = "" + continue + } + if m := alacrittyColor.FindStringSubmatch(line); m != nil && group != "" { + out[group+"."+m[1]] = strings.ToLower(m[2]) + } + } + return out +} + +// repoRoot walks UP looking for `flake.nix`. +// +// 🔴 IT DOES NOT COUNT `..`s, AND THAT MATTERS. A hardcoded depth is wrong the +// moment the module moves, and it is wrong SILENTLY — the file is simply not +// found, the test skips, and a skipped palette check reads exactly like a +// passing one. MEASURED: the first version of this file had six `..` where +// seven were needed and skipped on every run, reporting nothing. +// +// Returning "" means there is no checkout here at all — the real situation +// inside the `buildGoModule` sandbox, where `src` is the Go module alone. That +// is the ONLY case where skipping is honest. +func repoRoot() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + for i := 0; i < 12; i++ { + if _, err := os.Stat(filepath.Join(dir, "flake.nix")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} + +func alacrittyPath(t *testing.T) string { + t.Helper() + root := repoRoot() + if root == "" { + t.Skip("no repo checkout above this module (the Go-module-only sandbox)" + + " — palette NOT COMPARED") + } + // 🔴 INSIDE A CHECKOUT THE FILE MUST EXIST. A missing file here is a MOVED + // or DELETED Alacritty config, which is a finding, not a reason to skip. + p := filepath.Join(root, "nix", "programs", "alacritty", "default.nix") + if _, err := os.Stat(p); err != nil { + t.Fatalf("inside a checkout (%s) but the Alacritty config is missing: %v", root, err) + } + return p +} + +func TestThePaletteMatchesTheAlacrittyConfigBothWays(t *testing.T) { + theirs := alacrittyPalette(t, alacrittyPath(t)) + + // 🔴 POSITIVE CONTROL ON THE PARSER. A parser that matched nothing would + // make every comparison below vacuous — "no mismatches" over an empty map. + // The count is asserted against the ledger's own size, not a literal, so + // the control cannot rot when a colour is added on purpose. + if len(theirs) == 0 { + t.Fatal("the Alacritty parser extracted ZERO colours — every comparison " + + "below would be vacuous") + } + t.Logf("parsed %d colours from the Alacritty config, %d from the ledger", + len(theirs), len(PaletteHex)) + + ours := map[string]string{} + for k, v := range PaletteHex { + ours[k] = strings.ToLower(v) + } + + var problems []string + for k, want := range theirs { + got, ok := ours[k] + switch { + case !ok: + problems = append(problems, "the Go palette is MISSING "+k+" ("+want+")") + case got != want: + problems = append(problems, "colour "+k+": Go has "+got+", Alacritty has "+want) + } + } + for k, got := range ours { + if _, ok := theirs[k]; !ok { + problems = append(problems, "the Go palette has EXTRA "+k+" ("+got+"), which the Alacritty config does not") + } + } + sort.Strings(problems) + if len(problems) > 0 { + t.Errorf("the palette has drifted from nix/programs/alacritty/default.nix:\n %s", + strings.Join(problems, "\n ")) + } +} + +// 🔴 NO STYLE MAY SPELL A HEX LITERAL. Every colour has to come through the +// ledger, or the ledger is documentation of the palette rather than the palette +// itself — and a value with a second copy is a value that will drift from its +// guard. +func TestNoStyleSpellsAHexLiteralOutsideTheLedger(t *testing.T) { + raw, err := os.ReadFile("theme.go") + if err != nil { + t.Fatal(err) + } + text := string(raw) + ledgerStart := strings.Index(text, "var PaletteHex = map[string]string{") + ledgerEnd := strings.Index(text[ledgerStart:], "\n}\n") + if ledgerStart < 0 || ledgerEnd < 0 { + t.Fatal("cannot find the PaletteHex block — this guard is not reading what it thinks") + } + outside := text[:ledgerStart] + text[ledgerStart+ledgerEnd:] + + hex := regexp.MustCompile(`"#[0-9a-fA-F]{6}"`) + if found := hex.FindAllString(outside, -1); len(found) > 0 { + t.Errorf("hex literals outside the ledger: %v", found) + } + // POSITIVE CONTROL: the pattern MUST match inside the ledger, or "no + // matches outside" is a claim about a regex that matches nothing. + if n := len(hex.FindAllString(text[ledgerStart:ledgerStart+ledgerEnd], -1)); n == 0 { + t.Fatal("the hex pattern matched nothing even inside the ledger — " + + "this guard cannot see a violation") + } else { + t.Logf("hex pattern matched %d literals inside the ledger, 0 outside", n) + } +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/words.go b/nix/pkgs/tools/mention-review/src/internal/ui/words.go new file mode 100644 index 000000000..780ba6c27 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/words.go @@ -0,0 +1,233 @@ +package ui + +import ( + "fmt" + + "charm.land/lipgloss/v2" + + "github.com/innovation-upstream/devrc/mention-review/internal/ghapi" +) + +// 🔴 EVERY MEANING-BEARING STATE IS A WORD. +// +// This file is the ONE place a state becomes text. It exists so that claim is +// mechanically checkable rather than a habit: `words_test.go` walks +// `MeaningBearingStates()` — which is this file's own ledger — renders each +// one with ALL COLOUR REMOVED, and asserts a word survives. The day someone +// encodes approval as a green dot, that test goes red. +// +// The constraint is the operator's: the red/yellow/green severity circles +// render as one indistinguishable glyph in his font, so colour cannot carry +// meaning. Colour here is always decoration on top of text that already says +// the thing. + +// StateWord pairs the word with the style that decorates it. The word is the +// payload; the style is never load-bearing. +type StateWord struct { + Word string + Style lipgloss.Style +} + +// Render applies the decoration. +func (s StateWord) Render() string { return s.Style.Render(s.Word) } + +// ReviewWord maps `reviewDecision` onto a word. +// +// 🔴 THE EMPTY CASE IS `NONE`, NOT A BLANK. The server returns null for a PR +// nobody has reviewed — measured, on a real PR — and a blank cell reads as +// "this panel is broken" rather than "nobody has reviewed it". +func ReviewWord(decision string) StateWord { + switch decision { + case "APPROVED": + return StateWord{"APPROVED", styGood} + case "CHANGES_REQUESTED": + return StateWord{"CHANGES", styBad} + case "REVIEW_REQUIRED": + return StateWord{"REQUIRED", styWarn} + case "": + return StateWord{"NONE", styDim} + } + return StateWord{decision, styDim} +} + +// MergeWord maps `mergeable` + `mergeStateStatus` onto a word. +// +// ⚠ `UNKNOWN` IS A REAL AND COMMON ANSWER, NOT AN ERROR. GitHub computes +// mergeability lazily, so the first read of a PR frequently returns UNKNOWN — +// measured on a merged PR in this repo. Rendering it as CLEAN would be a +// guess presented as a fact; rendering it as CONFLICT would be alarming and +// wrong. It gets its own word. +func MergeWord(mergeable, stateStatus string, merged bool) StateWord { + if merged { + return StateWord{"MERGED", styMerged} + } + switch mergeable { + case "CONFLICTING": + return StateWord{"CONFLICT", styBad} + case "UNKNOWN", "": + return StateWord{"UNKNOWN", styDim} + } + // MERGEABLE — the finer detail lives in mergeStateStatus. + switch stateStatus { + case "CLEAN": + return StateWord{"CLEAN", styGood} + case "BLOCKED": + return StateWord{"BLOCKED", styBad} + case "BEHIND": + return StateWord{"BEHIND", styWarn} + case "UNSTABLE": + return StateWord{"UNSTABLE", styWarn} + case "DRAFT": + return StateWord{"DRAFT", styDim} + case "DIRTY": + return StateWord{"CONFLICT", styBad} + case "HAS_HOOKS": + return StateWord{"HOOKS", styWarn} + case "UNKNOWN", "": + return StateWord{"MERGEABLE", styGood} + } + return StateWord{stateStatus, styDim} +} + +// ChecksWord maps the status-check rollup onto a word. 🔴 THE COUNT IS PART OF +// THE WORD — "1 FAILING" tells the operator how much is wrong; a red dot does +// not. +func ChecksWord(c ghapi.CheckSummary) StateWord { + switch { + case c.Failing > 0: + return StateWord{fmt.Sprintf("%d FAILING", c.Failing), styBad} + case c.Pending > 0: + return StateWord{fmt.Sprintf("%d PENDING", c.Pending), styWarn} + case c.Total > 0 && c.State == "SUCCESS": + return StateWord{fmt.Sprintf("%d PASSING", c.Total), styGood} + case c.Total > 0: + return StateWord{fmt.Sprintf("%d CHECKS", c.Total), styDim} + } + // 🔴 NO CHECKS, spelled out. An empty cell is indistinguishable from a + // panel that failed to populate, and this repo's own CI posts nothing at + // all on a run that hits its task timeout — a state the operator has to be + // able to read off the screen. + return StateWord{"NO CHECKS", styDim} +} + +// PRStateWord maps a pull request's own lifecycle state onto a word. +func PRStateWord(state string, isDraft bool) StateWord { + if isDraft { + return StateWord{"DRAFT", styDim} + } + switch state { + case "OPEN": + return StateWord{"OPEN", styGood} + case "MERGED": + return StateWord{"MERGED", styMerged} + case "CLOSED": + return StateWord{"CLOSED", styBad} + } + return StateWord{state, styDim} +} + +// ThreadsWord maps the review-thread counts onto a word. +func ThreadsWord(t ghapi.ThreadSummary) StateWord { + switch { + case t.Unresolved > 0: + return StateWord{fmt.Sprintf("%d UNRESOLVED", t.Unresolved), styBad} + case t.Total > 0: + return StateWord{fmt.Sprintf("%d RESOLVED", t.Total), styGood} + } + return StateWord{"NO THREADS", styDim} +} + +// FileWord is the one-letter change marker plus its style. 🔴 A LETTER, NOT A +// COLOURED BULLET — `M`/`A`/`D`/`R` are the words here, and they survive with +// colour stripped. +func FileWord(changeType string) StateWord { + switch changeType { + case "ADDED": + return StateWord{"A", styGood} + case "REMOVED": + return StateWord{"D", styBad} + case "MODIFIED", "CHANGED": + return StateWord{"M", styWarn} + case "RENAMED": + return StateWord{"R", styAccent} + case "COPIED": + return StateWord{"C", styAccent} + } + return StateWord{"?", styDim} +} + +// LoadWord names where the fetch has got to. Every one of these is a state the +// operator can be looking at, so every one is a word. +type LoadState int + +const ( + LoadLoading LoadState = iota + LoadReady + LoadFailed +) + +func (l LoadState) Word() StateWord { + switch l { + case LoadLoading: + return StateWord{"LOADING", styWarn} + case LoadReady: + return StateWord{"READY", styGood} + } + return StateWord{"FAILED", styBad} +} + +// MeaningBearingStates is the LEDGER `words_test.go` walks. +// +// 🔴 TWO-WAY, AND THAT IS THE POINT. The test asserts every entry renders a +// word with colour removed AND that the ledger covers every constructor in this +// file. A state word that exists but is not listed here is a state nobody +// checked; a listed one that no longer renders is a dead entry. Both fail. +// +// ⚠ THE FIXTURES ARE DELIBERATELY NOT THE CONSTANTS THE ASSERTIONS NAME. The +// check counts are 3 and 7, never 0 or 1, so a mutant that hardcodes a literal +// cannot produce the expected value by accident. +func MeaningBearingStates() []StateWord { + return []StateWord{ + ReviewWord("APPROVED"), + ReviewWord("CHANGES_REQUESTED"), + ReviewWord("REVIEW_REQUIRED"), + ReviewWord(""), + + MergeWord("MERGEABLE", "CLEAN", false), + MergeWord("MERGEABLE", "BLOCKED", false), + MergeWord("MERGEABLE", "BEHIND", false), + MergeWord("MERGEABLE", "UNSTABLE", false), + MergeWord("CONFLICTING", "DIRTY", false), + MergeWord("UNKNOWN", "", false), + MergeWord("MERGEABLE", "CLEAN", true), + + ChecksWord(ghapi.CheckSummary{State: "FAILURE", Total: 7, Failing: 3}), + ChecksWord(ghapi.CheckSummary{State: "PENDING", Total: 7, Pending: 3}), + ChecksWord(ghapi.CheckSummary{State: "SUCCESS", Total: 7}), + ChecksWord(ghapi.CheckSummary{}), + + PRStateWord("OPEN", false), + PRStateWord("OPEN", true), + PRStateWord("MERGED", false), + PRStateWord("CLOSED", false), + + ThreadsWord(ghapi.ThreadSummary{Total: 7, Unresolved: 3}), + ThreadsWord(ghapi.ThreadSummary{Total: 7}), + ThreadsWord(ghapi.ThreadSummary{}), + + FileWord("ADDED"), + FileWord("REMOVED"), + FileWord("MODIFIED"), + FileWord("RENAMED"), + FileWord("COPIED"), + + LoadLoading.Word(), + LoadReady.Word(), + LoadFailed.Word(), + + {Word: ghapi.AuthNoToken.Word(), Style: styBad}, + {Word: ghapi.AuthRejected.Word(), Style: styBad}, + {Word: ghapi.AuthNotFound.Word(), Style: styBad}, + {Word: ghapi.AuthRateLimited.Word(), Style: styWarn}, + } +} diff --git a/nix/pkgs/tools/mention-review/src/internal/ui/words_test.go b/nix/pkgs/tools/mention-review/src/internal/ui/words_test.go new file mode 100644 index 000000000..1b1a2b3e4 --- /dev/null +++ b/nix/pkgs/tools/mention-review/src/internal/ui/words_test.go @@ -0,0 +1,272 @@ +package ui + +import ( + "regexp" + "strings" + "testing" + + "github.com/innovation-upstream/devrc/mention-review/internal/ghapi" + "github.com/innovation-upstream/devrc/mention-review/internal/udiff" +) + +// 🔴 LAYER 3(d) — MEANING IS NEVER COLOUR-ONLY. +// +// Every meaning-bearing state is rendered with ALL COLOUR REMOVED and the state +// WORD must survive. This goes red the day someone encodes approval as a green +// dot. It is the operator's font constraint turned into a gate: the +// red/yellow/green severity circles render as ONE indistinguishable glyph in +// his font, so colour cannot carry meaning. +// +// ⚠ MECHANISM NOTE, BECAUSE THE OBVIOUS SPELLING IS A DELETED API. Every guide +// still says `lipgloss.SetColorProfile(termenv.Ascii)` — Lip Gloss v2 REMOVED +// the whole `Renderer` concept, including `SetColorProfile`, `NewRenderer` and +// `ColorProfile`. Stripping the SGR sequences out of the rendered bytes is +// used instead: it is the definition of "colour removed", it cannot silently +// no-op the way a mis-set profile can, and `TestTheColourStripperActuallyStrips` +// below proves it moves. + +// sgr matches every ANSI escape sequence lipgloss can emit. +var sgr = regexp.MustCompile(`\x1b\[[0-9;:]*[a-zA-Z]|\x1b\][^\x07\x1b]*(\x07|\x1b\\)`) + +func stripANSI(s string) string { return sgr.ReplaceAllString(s, "") } + +// 🔴 VALIDATE THE INSTRUMENT BEFORE READING ITS VERDICT. +// +// If `stripANSI` stripped nothing, every assertion below would find its word +// anyway — because the word was there all along — and the suite would pass +// while measuring NOTHING. This asserts the pair: styled output DOES carry +// escape bytes, and stripping removes them while leaving the text. +func TestTheColourStripperActuallyStrips(t *testing.T) { + styled := styGood.Render("APPROVED") + if !strings.Contains(styled, "\x1b[") { + t.Fatal("the styled render carries NO escape bytes — this test cannot " + + "tell colour-removed from coloured, so every guard below is vacuous") + } + bare := stripANSI(styled) + if strings.Contains(bare, "\x1b") { + t.Errorf("stripANSI left escape bytes: %q", bare) + } + if bare != "APPROVED" { + t.Errorf("stripANSI = %q, want %q", bare, "APPROVED") + } + // The NEGATIVE control: a plain string must come back unchanged, so the + // stripper cannot be a function that deletes text generally. + if got := stripANSI("1 FAILING"); got != "1 FAILING" { + t.Errorf("stripANSI mangled plain text: %q", got) + } +} + +// 🔴 THE LEDGER WALK. Every entry in MeaningBearingStates() must render at +// least one WORD with colour removed. +func TestEveryMeaningBearingStateRendersAWordWithColourRemoved(t *testing.T) { + states := MeaningBearingStates() + if len(states) == 0 { + t.Fatal("the ledger is EMPTY — this guard would pass wired to nothing") + } + word := regexp.MustCompile(`[A-Z]{2,}`) + for _, s := range states { + bare := stripANSI(s.Render()) + if strings.TrimSpace(bare) == "" { + t.Errorf("state %q renders NOTHING with colour removed", s.Word) + continue + } + // A single letter is a legitimate word here — `M`/`A`/`D`/`R` are the + // Files panel's markers — so the pattern allows either an uppercase + // run or a lone uppercase letter, and nothing else. + if !word.MatchString(bare) && !regexp.MustCompile(`^[A-Z?]$`).MatchString(strings.TrimSpace(bare)) { + t.Errorf("state %q renders %q with colour removed — no WORD survives", + s.Word, bare) + } + } +} + +// 🔴 MUTATION CONTROL, NAMED IN ADVANCE (§5.5): "replace the word APPROVED with +// a green glyph" must fail with THIS guard's own error. +// +// It is run here as a POSITIVE CONTROL rather than left to a manual mutation, +// so the guard's ability to catch that specific defect is re-proved on every +// run instead of once by hand. +func TestAGlyphOnlyStateIsCaughtByTheGuard(t *testing.T) { + glyph := StateWord{Word: "●", Style: styGood} + bare := strings.TrimSpace(stripANSI(glyph.Render())) + word := regexp.MustCompile(`[A-Z]{2,}`) + single := regexp.MustCompile(`^[A-Z?]$`) + if word.MatchString(bare) || single.MatchString(bare) { + t.Fatalf("a bare glyph %q passed the word test — the guard is walkable "+ + "by encoding state as colour plus a symbol, which is exactly what "+ + "it exists to prevent", bare) + } +} + +// 🔴 THE DIFF ROWS ARE MEANING-BEARING STATES TOO, AND NOTHING GUARDED THEM. +// +// MEASURED: deleting the `"+"` from `plainDiffLine`'s add case — which makes an +// added line and a context line IDENTICAL once colour is stripped, i.e. the +// exact colour-only encoding §5.3(d) exists to forbid — SURVIVED the whole +// suite. `MeaningBearingStates()` covered the Overview's words and not one row +// of the pane the tool is FOR. +// +// A guard whose description says "every meaning-bearing state" while its +// implementation inspects one surface is the shape this repo calls "reading as +// coverage while providing none". This closes it. +func TestEveryDiffRowKindIsDistinguishableWithColourRemoved(t *testing.T) { + // The same text on every row, so the ONLY thing that can distinguish them + // is the marker. If the marker goes, these collapse onto each other. + const body = "ctx := build(req)" + rows := map[udiff.Op]string{ + udiff.OpAdd: "+", + udiff.OpDelete: "-", + udiff.OpContext: " ", + } + + seen := map[string]udiff.Op{} + for op, wantMarker := range rows { + bare := stripANSI(diffLineStyle(op).Render(plainDiffLine(udiff.Line{Op: op, Text: body}))) + if !strings.HasPrefix(bare, wantMarker) { + t.Errorf("op %v renders %q with colour removed, want it to start with %q", + op, bare, wantMarker) + } + if prev, dup := seen[bare]; dup { + t.Errorf("ops %v and %v render IDENTICALLY with colour removed (%q) — "+ + "the difference is carried by colour alone, which the operator's "+ + "font cannot show", prev, op, bare) + } + seen[bare] = op + } + if len(seen) != len(rows) { + t.Errorf("%d distinct rows out of %d", len(seen), len(rows)) + } +} + +// --- the specific mappings, asserted against literals ------------------------ +// +// 🔴 NOT DERIVED FROM THE IMPLEMENTATION. Each expected string is written from +// what the operator must be able to read off the screen. + +func TestReviewDecisionWords(t *testing.T) { + for in, want := range map[string]string{ + "APPROVED": "APPROVED", + "CHANGES_REQUESTED": "CHANGES", + "REVIEW_REQUIRED": "REQUIRED", + "": "NONE", // 🔴 the null case is a WORD, not a blank + } { + if got := ReviewWord(in).Word; got != want { + t.Errorf("ReviewWord(%q) = %q, want %q", in, got, want) + } + } +} + +func TestMergeWords(t *testing.T) { + cases := []struct { + mergeable, state string + merged bool + want string + }{ + {"MERGEABLE", "CLEAN", false, "CLEAN"}, + {"MERGEABLE", "BLOCKED", false, "BLOCKED"}, + {"MERGEABLE", "BEHIND", false, "BEHIND"}, + {"MERGEABLE", "UNSTABLE", false, "UNSTABLE"}, + {"CONFLICTING", "DIRTY", false, "CONFLICT"}, + // ⚠ UNKNOWN IS A REAL AND COMMON ANSWER — GitHub computes mergeability + // lazily and the first read of a PR frequently returns it. Rendering it + // as CLEAN would be a guess presented as a fact. + {"UNKNOWN", "UNKNOWN", false, "UNKNOWN"}, + {"", "", false, "UNKNOWN"}, + // Merged wins over everything, including a stale CONFLICTING. + {"CONFLICTING", "DIRTY", true, "MERGED"}, + } + for _, c := range cases { + if got := MergeWord(c.mergeable, c.state, c.merged).Word; got != c.want { + t.Errorf("MergeWord(%q,%q,%v) = %q, want %q", + c.mergeable, c.state, c.merged, got, c.want) + } + } +} + +// 🔴 THE COUNT IS PART OF THE WORD. "1 FAILING" tells the operator how much is +// wrong; a red dot does not. +// +// ⚠ THE FIXTURE COUNTS ARE 3 AND 7, NEVER 0 OR 1. A fixture that can only ever +// produce the constant's own value cannot see a mutant that hardcodes the +// literal — feeding a value the constant CANNOT equal is the control. +func TestChecksWordCarriesTheCount(t *testing.T) { + cases := []struct { + in ghapi.CheckSummary + want string + }{ + {ghapi.CheckSummary{State: "FAILURE", Total: 7, Failing: 3}, "3 FAILING"}, + {ghapi.CheckSummary{State: "PENDING", Total: 7, Pending: 3}, "3 PENDING"}, + {ghapi.CheckSummary{State: "SUCCESS", Total: 7}, "7 PASSING"}, + {ghapi.CheckSummary{State: "EXPECTED", Total: 7}, "7 CHECKS"}, + // 🔴 NO CHECKS, spelled out. An empty cell is indistinguishable from a + // panel that failed to populate — and this repo's own CI posts NOTHING + // at all on a run that hits its task timeout, a state the operator has + // to be able to read off the screen. + {ghapi.CheckSummary{}, "NO CHECKS"}, + } + for _, c := range cases { + if got := ChecksWord(c.in).Word; got != c.want { + t.Errorf("ChecksWord(%+v) = %q, want %q", c.in, got, c.want) + } + } + // FAILING outranks PENDING: a PR with both must not read as merely pending. + both := ChecksWord(ghapi.CheckSummary{Total: 7, Failing: 3, Pending: 2}).Word + if both != "3 FAILING" { + t.Errorf("failing+pending = %q, want the FAILING word to win", both) + } +} + +func TestThreadsWordCarriesTheCount(t *testing.T) { + if got := ThreadsWord(ghapi.ThreadSummary{Total: 7, Unresolved: 3}).Word; got != "3 UNRESOLVED" { + t.Errorf("= %q", got) + } + if got := ThreadsWord(ghapi.ThreadSummary{Total: 7}).Word; got != "7 RESOLVED" { + t.Errorf("= %q", got) + } + if got := ThreadsWord(ghapi.ThreadSummary{}).Word; got != "NO THREADS" { + t.Errorf("= %q", got) + } +} + +func TestFileWordsAreLettersNotBullets(t *testing.T) { + for in, want := range map[string]string{ + "ADDED": "A", "REMOVED": "D", "MODIFIED": "M", + "RENAMED": "R", "COPIED": "C", "CHANGED": "M", + } { + if got := FileWord(in).Word; got != want { + t.Errorf("FileWord(%q) = %q, want %q", in, got, want) + } + } + // An unrecognised change type gets `?`, not a silent blank and not a + // guess at MODIFIED — a new GitHub status must not render as an ordinary + // edit. + if got := FileWord("TELEPORTED").Word; got != "?" { + t.Errorf("FileWord(unknown) = %q, want %q", got, "?") + } +} + +func TestDraftOutranksTheLifecycleState(t *testing.T) { + if got := PRStateWord("OPEN", true).Word; got != "DRAFT" { + t.Errorf("a draft PR reads as %q", got) + } + if got := PRStateWord("OPEN", false).Word; got != "OPEN" { + t.Errorf("= %q", got) + } +} + +// The auth states are words too, and their hints name the FIX rather than +// restating the state. +func TestAuthStateWordsAreDistinguishable(t *testing.T) { + if ghapi.AuthNoToken.Word() == ghapi.AuthRejected.Word() { + t.Fatal("NO TOKEN and TOKEN REJECTED render the same word — the whole " + + "point is that the FIXES differ") + } + if !strings.Contains(ghapi.AuthNoToken.Hint(), "gh auth login") { + t.Errorf("NO TOKEN hint does not name the fix: %q", ghapi.AuthNoToken.Hint()) + } + if strings.Contains(ghapi.AuthRejected.Hint(), "gh auth login") && + !strings.Contains(ghapi.AuthRejected.Hint(), "refused") { + t.Errorf("TOKEN REJECTED hint does not say the token was refused: %q", + ghapi.AuthRejected.Hint()) + } +} diff --git a/scripts/README.md b/scripts/README.md index 4af4359b0..9fe7a6b42 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -103,9 +103,10 @@ Click actions for those blocks: | script | purpose | |---|---| -| `gate.sh` | **run the gate through this.** Wraps both runners, sends their full output to a LOG FILE (not a pipe — the pipe is what destroyed the status for four agents in one day) and prints a bounded summary, so **its exit code is authoritative**. Cross-checks that status against the runner's own `RESULT:` line and exits **90 = could-not-vouch** on a disagreement, a missing verdict, or a `panic: test timed out` — a different finding from "the tests failed" | +| `gate.sh` | **run the gate through this.** Wraps ALL THREE runners (`--tier pytest\|node\|go\|all`; **`both` is an alias for `all`** so a caller who typed it before Go existed does not silently skip a language), sends their full output to a LOG FILE (not a pipe — the pipe is what destroyed the status for four agents in one day) and prints a bounded summary, so **its exit code is authoritative**. Cross-checks that status against the runner's own `RESULT:` line and exits **90 = could-not-vouch** on a disagreement, a missing verdict, or a `panic: test timed out` — a different finding from "the tests failed" | | `run-tests.sh` | **single source of truth** for running the Python suites. Per-target collected-test floors (`TARGET_FLOORS`, pinned two-way against the target list); the global floor is their SUM, never hand-written. `--check-targets` / `--check-floors` validate the two tables in milliseconds | | `run-node-tests.sh` | single source of truth for running the `.mjs` suites. Per-suite floors + a two-way discovery pin; global floor derived as their sum | +| `run-go-tests.sh` | single source of truth for the Go suites (`mention-review`). Same guard shape as the other two — per-package floors, a two-way package pin, `SCOPE`/`RESULT` from one writer behind an EXIT trap — plus two of its own: it exits **3** when `go` is missing (a missing toolchain is never a pass) and caps SKIPS at **0**, because the palette guard legitimately skips in the Go-module-only sandbox and must NOT skip here. 🔴 It counts `go test -json` records rather than reading an exit code, and the pattern is ORDER-INDEPENDENT: an order-dependent one matched on the dev host's go 1.25 and matched NOTHING under the sandbox's go 1.26, which the per-package floor is what caught. `--check-packages` validates the pin in milliseconds and reports `SCOPE: NONE` | | `ship.sh` | converge BOTH NixOS hosts (workbench + laptop) to `origin/main`, then verify — in **three independent halves**, because each is blind to the next: git/deploy state (rc 11), every managed path RESOLVES (rc 12), and every managed path serves the content the repo is at (rc 13). rc 12 reads its manifest out of the host's OWN active generation, so an old generation is self-consistent and passes it green — measured 2026-08-19, the workbench served the pre-#611 `~/.claude/RULES.md` under "488 checked, 0 dangling, 0 absent". rc 13 compares by CONTENT with git as the oracle (blob in the working tree = current, blob elsewhere in the object store = a HISTORICAL version = stale, blob git has never seen = rendered, not copied, so excluded), which needs no manifest→repo path table to rot. `mkOutOfStoreSymlink` targets resolve back into the repo and so can never be stale: they are counted separately, never as evidence, and the EXAMINED count printed is repo-sourced only — 0 of those is RED | | `sync-clones.py` | **the ACTIVE counterpart to `drift-check.sh`'s deadman** — fast-forward the shared base clones, and report per clone WHY not. Thin CLI over `lib/shared_clone_sync.py`. Exists because a `git merge --ff-only` in a cron accomplishes nothing here: the four interesting outcomes (fast-forwarded 606 commits / already current / refused because the tree is dirty / refused because the branch has local commits) all end that command with nothing worth reading, and the no-op **exits 0**. So the product is the CLASSIFICATION — one status per clone, each with its own exit code (3 dirty, 4 diverged, 5 no-upstream, 6 detached, 7 not-a-repo, 8 fetch-failed, 9 ff-refused-by-git) and its own sentinel phrase. 🔴 `synced` and `current` **both exit 0 and are not the same answer** — they differ in status, sentinel and a `moved` count, so read `--json`, not just the status. 🔴 It **never** stashes, resets or discards: dirt that overlaps the incoming commits is refused with the paths named. A run that examined **zero** repositories exits **2**, never 0. With no arguments it discovers primary clones (linked worktrees are skipped — their `.git` is a file) under `$DEVRC_CLONE_ROOTS` or `~/workspace` and **prints what it chose** before touching anything; that variable set-but-EMPTY is an error, not a fall-back to the whole workspace | | `drift-check.sh` | **passive deadman** — is either host silently no longer receiving changes? READ-ONLY: fetches and reports, never fixes. Distinct rc per condition (8 un-pushed/diverged, 10 behind, 12 not-on-main, 3/4 cannot-evaluate, **2 checked-no-host**, **13 remote unreachable for `DRIFT_UNREACHABLE_ESCALATE` CONSECUTIVE runs**), aligned with `ship.sh`'s legend. Runs unattended as the `drift-check` systemd-user timer; drift ⇒ non-zero ⇒ the existing `notify-failure@` dunst toast. **Two rc's need their policy read, not just their name:** rc 13 is *not* "the laptop was unreachable this run" — a single miss is reported loudly and contributes **nothing** to the exit code, because the timer is workbench-only and its remote leg is a laptop that is routinely shut; it escalates only after N consecutive misses (default 4 ≈ 24h at the 6h cadence), a streak persisted under `$DRIFT_STATE_DIR` and reset the moment the host answers — *or immediately* if that streak cannot be persisted, since "how long" is then unknowable. rc 2 is usage **plus** any run that ended up observing no host at all (`--no-local --no-remote`, or `--no-local` with the remote unreachable below threshold) — a 0 there would be a green from a checker wired to nothing. Below-threshold softening applies to the remote leg only: a local rc 8 with the laptop shut still exits 8 | diff --git a/scripts/gate.sh b/scripts/gate.sh index 8f826adae..dc2315e61 100755 --- a/scripts/gate.sh +++ b/scripts/gate.sh @@ -62,10 +62,17 @@ # A MISSING scope line is therefore NOT a pass either; it lands in the same 91. # # Usage: -# scripts/gate.sh [--tier pytest|node|both] [--set hermetic|all] +# scripts/gate.sh [--tier pytest|node|go|all] [--set hermetic|all] # [--timeout SECS] [--log-dir DIR] [ROOT] # -# --tier both (default) run both runners; the gate is red if either is. +# --tier all (default) run ALL THREE runners; the gate is red if any +# is. 🔴 `both` IS ACCEPTED AND MEANS `all` — it is kept as +# an alias rather than as its old two-tier meaning, on +# purpose: a caller who typed `both` before Go existed +# wanted "the whole gate", and silently excluding a whole +# language from it is the "list that only grows when +# somebody remembers" defect the runners' own headers are +# written against. The alias errs toward MORE coverage. # --set passed through to run-tests.sh (pytest tier only). # --timeout SECS wall-clock cap per tier (default 3600, 0 disables). A # tier that hits it is FAIL with reason=timeout, never a @@ -82,6 +89,15 @@ # verdict. Listed because gate.sh reads it in that # refusal — see DEVRC_GATE_ALLOW_AMBIENT below. # DEVRC_TARGETS same: run-tests.sh's target narrowing, refused here. +# MIN_GO_TESTS the go tier's equivalent of MIN_TESTS — its GLOBAL +# collected-test floor — and refused here for the same +# reason. Undocumented for as long as the go tier had +# existed: the derivation that pins this block against +# what the script reads had gone blind on a wrapped +# refusal list, so it asked for nothing. +# MAX_GO_SKIPS the go tier's skip budget, which is 0. Overriding it +# lets skipped tests pass as run, so it weakens what a +# green means and is refused alongside the rest. # DEVRC_GATE_NO_REEXEC =1 to run against the ambient PATH instead of # re-entering `nix develop`. See the RE-EXEC block. # DEVRC_GATE_ENV =1 means "already inside a sanctioned gate @@ -114,7 +130,8 @@ # the more actionable finding, so narrowing only decides the verdict # of a run that otherwise passed. # -# TEST SEAM: DEVRC_GATE_PYTEST_RUNNER / DEVRC_GATE_NODE_RUNNER override the +# TEST SEAM: DEVRC_GATE_PYTEST_RUNNER / DEVRC_GATE_NODE_RUNNER / +# DEVRC_GATE_GO_RUNNER override the # runner paths. They exist so the negative controls in # scripts/tests/test_gate_exit_truthfulness.py can drive this script against a # runner that is forced red, forced to hang, or forced to LIE (exit 0 while @@ -138,7 +155,7 @@ if [ ! -f "$GATE_SELF" ]; then exit 2 fi -TIER="both" +TIER="all" SET="hermetic" TIMEOUT="${DEVRC_GATE_TIMEOUT:-3600}" LOG_DIR="" @@ -146,7 +163,7 @@ ROOT="" while [ $# -gt 0 ]; do case "$1" in - --tier) TIER="${2:-both}"; shift; [ $# -gt 0 ] && shift ;; + --tier) TIER="${2:-all}"; shift; [ $# -gt 0 ] && shift ;; --tier=*) TIER="${1#*=}"; shift ;; --set) SET="${2:-hermetic}"; shift; [ $# -gt 0 ] && shift ;; --set=*) SET="${1#*=}"; shift ;; @@ -163,8 +180,12 @@ while [ $# -gt 0 ]; do done case "$TIER" in - pytest|node|both) : ;; - *) echo "gate: FATAL — unknown --tier '$TIER' (want pytest|node|both)" >&2; exit 2 ;; + # `both` is an ALIAS for `all`, normalised here so every consumer below sees + # one spelling. See the usage block for why it widened rather than kept its + # old two-tier meaning. + both) TIER="all" ;; + pytest|node|go|all) : ;; + *) echo "gate: FATAL — unknown --tier '$TIER' (want pytest|node|go|all)" >&2; exit 2 ;; esac case "$TIMEOUT" in ''|*[!0-9]*) echo "gate: FATAL — --timeout must be a whole number of seconds, got '$TIMEOUT'" >&2; exit 2 ;; @@ -177,7 +198,7 @@ esac # only after paying the full run; catching them here costs nothing and names the # variable, which is the whole remedy. # -# 🔴 ALL FOUR, NOT JUST `DEVRC_TARGETS`. An earlier revision of this block said +# 🔴 ALL SEVEN, NOT JUST `DEVRC_TARGETS`. An earlier revision of this block said # that variable was "the ONLY way its pytest tier gets narrowed", and that was # false in three directions — an over-broad claim beside an asymmetric refusal, # which reads as coverage and provides none: @@ -187,6 +208,15 @@ esac # `RESULT: PASS (exit=0)` yields a green gate with # NOTHING run, and no later check can see it # MIN_TESTS overrides the collected-test floor +# DEVRC_GATE_GO_RUNNER the same hazard as its two siblings, one tier +# over. Added WITH the go tier rather than after +# somebody noticed — the asymmetric refusal above +# is the defect this list exists to avoid, and a +# new tier that skips it reintroduces it. +# MIN_GO_TESTS overrides the go tier's global floor +# MAX_GO_SKIPS overrides its skip budget, which is 0 — an +# ambient value here turns "every test skipped" +# into a green tier # The runner-replacement pair is the worst of them and is exactly what the # in-repo tests use to drive this script against a forced-green stub. That seam # is legitimate FOR TESTS and must never be reachable by accident from a shell. @@ -195,7 +225,8 @@ esac # mirror defect (they asked for something and got something else with no word # said), and this script's doctrine is that such a mistake must be loud. _gate_ambient=() -for _v in DEVRC_TARGETS DEVRC_GATE_PYTEST_RUNNER DEVRC_GATE_NODE_RUNNER MIN_TESTS; do +for _v in DEVRC_TARGETS DEVRC_GATE_PYTEST_RUNNER DEVRC_GATE_NODE_RUNNER \ + DEVRC_GATE_GO_RUNNER MIN_TESTS MIN_GO_TESTS MAX_GO_SKIPS; do [ -n "${!_v+x}" ] && _gate_ambient+=("$_v=${!_v}") done # 🔴 COMPARE AGAINST THE STRING, not emptiness. `-z` accepted ANY non-empty @@ -277,6 +308,7 @@ cd "$ROOT" || { echo "gate: FATAL — cannot cd to ROOT=$ROOT" >&2; exit 2; } PYTEST_RUNNER="${DEVRC_GATE_PYTEST_RUNNER:-$ROOT/scripts/run-tests.sh}" NODE_RUNNER="${DEVRC_GATE_NODE_RUNNER:-$ROOT/scripts/run-node-tests.sh}" +GO_RUNNER="${DEVRC_GATE_GO_RUNNER:-$ROOT/scripts/run-go-tests.sh}" if [ -z "$LOG_DIR" ]; then LOG_DIR="$(mktemp -d -t devrc-gate-XXXXXX)" @@ -466,12 +498,15 @@ run_tier() { # $1 = label, $2.. = command echo } -if [ "$TIER" = "pytest" ] || [ "$TIER" = "both" ]; then +if [ "$TIER" = "pytest" ] || [ "$TIER" = "all" ]; then run_tier pytest bash "$PYTEST_RUNNER" --set "$SET" "$ROOT" fi -if [ "$TIER" = "node" ] || [ "$TIER" = "both" ]; then +if [ "$TIER" = "node" ] || [ "$TIER" = "all" ]; then run_tier node bash "$NODE_RUNNER" "$ROOT" fi +if [ "$TIER" = "go" ] || [ "$TIER" = "all" ]; then + run_tier go bash "$GO_RUNNER" "$ROOT" +fi echo "======================== GATE ========================" for l in "${TIER_LINES[@]}"; do echo " $l"; done diff --git a/scripts/main-green-check.sh b/scripts/main-green-check.sh index cb5da3114..fcf68bfb1 100755 --- a/scripts/main-green-check.sh +++ b/scripts/main-green-check.sh @@ -421,7 +421,7 @@ tier_verdict() { ATTEMPT_VERDICT="" attempt_all_tiers() { local attempt="$1" tier rc v overall=green - for tier in pytests nodetests; do + for tier in pytests nodetests gotests; do run_tier "$tier" "$attempt"; rc=$? v="$(tier_verdict "$LOGDIR/${tier}.attempt${attempt}.log" "$rc")" say " attempt $attempt · $tier · rc=$rc · verdict=$v" diff --git a/scripts/run-go-tests.sh b/scripts/run-go-tests.sh new file mode 100755 index 000000000..8e720bb5f --- /dev/null +++ b/scripts/run-go-tests.sh @@ -0,0 +1,360 @@ +#!/usr/bin/env bash +# +# devrc GO test-suite runner — the single source of truth for "run the Go tests". +# +# 🔴 A THIRD TIER, AND IT IS A REAL MIGRATION COST RATHER THAN A FILE. +# This repo's gate had TWO tiers (`pytests`, `nodetests`) and Tekton builds them +# as `LEG ∈ {pytests, nodetests}`. Go is a third: it touches `flake.nix` checks, +# `gate.sh`, and anything that enumerates legs. The proposal that asked for this +# flagged it as easy to under-budget; this file exists so it is not. +# +# Used by BOTH: +# 1. the flake check (`nix build .#checks.x86_64-linux.gotests`) — the nix +# sandbox, no network, `go` pinned by flake.nix. +# 2. a dev-host invocation: `bash scripts/run-go-tests.sh` from the repo root, +# or through `scripts/gate.sh --tier go`. +# +# 🔴 WHY THE SAME GUARD SHAPE AS THE OTHER TWO RUNNERS, RATHER THAN A BARE +# `go test ./...`. Every one of these exists because a green exit code lied: +# +# 1. COUNT THE TESTS, DON'T READ THE EXIT CODE. `go test` over a package with +# no test files prints `[no test files]` and exits 0. A build tag typo, a +# `_test.go` file that stopped compiling into the right package, or a +# module path change can therefore take this tier fully green while running +# NOTHING. The run parses `go test -json` and fails below a floor. +# +# 2. A TWO-WAY PACKAGE PIN. Discovery alone makes a whole package going silent +# invisible: delete `internal/udiff/udiff_test.go` and a bare `./...` just +# collects fewer tests and — above a global floor — still says PASS. So +# every discovered package with tests must appear in `PACKAGES`, and every +# `PACKAGES` entry must be discovered. It fails BOTH ways. +# +# 3. PER-PACKAGE FLOORS, not just a global one. One package collapsing to +# near-zero must not be absorbed by the others' totals. +# +# 4. SKIPS ARE REPORTED AND CAPPED. A test that skips itself is worse than no +# test. `internal/ui`'s palette guard skips when there is no repo checkout +# above the module — legitimate inside the Go-module-only `buildGoModule` +# sandbox, and NOT legitimate here, where this runner always runs from a +# checkout. A skip budget of zero is what makes that difference visible. +# +# 5. THE VERDICT LINE CARRIES THE EXIT STATUS (`RESULT: FAIL (exit=1)`), from +# one writer behind an EXIT trap. Every consumer pipes this output and a +# pipeline reports the LAST command's status, so the truth has to be in the +# content. +# +# 6. IT STATES ITS SCOPE. `gate.sh` requires to SEE `SCOPE: FULL` from every +# tier before it may print a gate PASS — a positive control, so a runner +# that says nothing is "cannot vouch" rather than "ran everything". +# +# Env overrides (defaults are the point — raise them, don't lower them casually): +# MIN_GO_TESTS one-off override of the GLOBAL floor, otherwise DERIVED as +# the sum of the per-package floors. +# MAX_GO_SKIPS one-off override of the skip budget (default 0). +# +# Usage: +# scripts/run-go-tests.sh [--check-packages] [ROOT] +# --check-packages run GUARD 2 only (discovery + the two-way pin) and exit. +# No `go`, no tests — cheap enough for a unit test, and it +# reports `SCOPE: NONE`, never FULL. +# +# Exit: 0 all packages passed and every guard held +# 1 a test failed +# 2 a usage or environment error +# 3 `go` is not on PATH (a MISSING TOOLCHAIN, never a pass) +# 4 a guard tripped (floor, package pin, or skip budget) + +set -uo pipefail + +# --- GUARD 5: the verdict line CARRIES the exit status ------------------------- +VERDICT_EMITTED=0 +_emit_verdict() { + local rc="$1" + [ "$VERDICT_EMITTED" -eq 0 ] || return 0 + VERDICT_EMITTED=1 + _emit_scope + if [ "$rc" -eq 0 ]; then + echo "RESULT: PASS (exit=0)" + else + echo "RESULT: FAIL (exit=$rc)" + fi +} + +# --- GUARD 6: this runner states its scope too --------------------------------- +# 🔴 THE PRE-RESOLUTION DEFAULT IS UNKNOWN, NOT FULL. Before the scope is +# resolved the honest answer is UNKNOWN; an early exit that inherited a `FULL` +# would emit a whole-suite coverage claim from a run that collected nothing. +SCOPE_STATE="UNKNOWN" +SCOPE_DETAIL="the scope has not been resolved yet" +SCOPE_EMITTED=0 +_emit_scope() { + [ "$SCOPE_EMITTED" -eq 0 ] || return 0 + SCOPE_EMITTED=1 + echo "SCOPE: ${SCOPE_STATE} (${SCOPE_DETAIL})" +} +_on_exit() { _emit_verdict "$?"; } +trap '_on_exit' EXIT +trap 'exit 143' TERM +trap 'exit 130' INT + +CHECK_PACKAGES_ONLY=0 +ROOT="" +while [ $# -gt 0 ]; do + case "$1" in + --check-packages) CHECK_PACKAGES_ONLY=1; shift ;; + -h|--help) sed -n '1,70p' "${BASH_SOURCE[0]}"; SCOPE_STATE="NONE" + SCOPE_DETAIL="--help printed, no tests run"; exit 0 ;; + -*) echo "run-go-tests: unknown flag $1" >&2 + SCOPE_STATE="NONE"; SCOPE_DETAIL="an unrecognised flag, no tests run" + exit 2 ;; + *) ROOT="$1"; shift ;; + esac +done + +# --- GUARD: NO TEST MAY OPERATE ON THE REPO THE SUITE RUNS FROM ---------------- +# 🔴 BEFORE the ROOT block below, not after it: with GIT_DIR set and no +# GIT_WORK_TREE, git takes the CWD as the work tree and `rev-parse +# --show-toplevel` returns the wrong directory. The set is owned by +# `scripts/testlib/gitenv.py::REPO_POINTER_VARS` and the reason it is spelled +# per-runner rather than sourced is in `scripts/run-tests.sh`'s copy of this +# header. +DEVRC_GIT_REPO_POINTERS=( + GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE + GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES + GIT_NAMESPACE GIT_PREFIX GIT_GRAFT_FILE GIT_SHALLOW_FILE GIT_CONFIG +) +DEVRC_GITENV_CONTROL_VARS=( + DEVRC_GITENV_PROTECT # which git dirs the detector watches + DEVRC_GITENV_MODE # enforce | report | auto +) +unset "${DEVRC_GIT_REPO_POINTERS[@]}" +unset "${DEVRC_GITENV_CONTROL_VARS[@]}" + +if [ -z "$ROOT" ]; then + ROOT="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel 2>/dev/null || true)" + [ -n "$ROOT" ] || ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fi +cd "$ROOT" || { echo "run-go-tests: cannot cd to ROOT=$ROOT" >&2; exit 2; } + +# --- the pinned module + package table ----------------------------------------- +# "||". +# +# MEASURED 2026-09-14 on the dev host by this runner itself, counting `pass` + +# `fail` + `skip` records from `go test -json` (so SUBTESTS count, which is why +# `internal/argv` reports 28 rather than its 8 top-level funcs): +# +# cmd/mention-review 5 the argv contract against the BINARY +# internal/argv 28 the ported argv table, parametrised +# internal/ghapi 19 auth classification + GraphQL/REST decoding +# internal/udiff 12 diff parsing + hunk navigation +# internal/ui 53 Step, the ledgers, the words, one end-to-end +# +# ⚠ THE FIRST VERSION OF THIS TABLE CARRIED GUESSED FLOORS — 9/7/10/11/22, typed +# before anything had been run. `cmd/mention-review` has FIVE tests, so its +# floor of 9 was unsatisfiable and the tier was red on a green suite. A floor is +# a function of a MEASUREMENT; these are derived from the numbers above by the +# same rule the other two runners use: `m - min(50, max(1, m/20))`. +# Raise one when a package grows. NEVER lower one to get green. +GO_MODULE="nix/pkgs/tools/mention-review/src" +PACKAGES=( + "cmd/mention-review|4" + "internal/argv|27" + "internal/ghapi|18" + "internal/udiff|11" + "internal/ui|51" +) + +# --- GUARD 2: discovery + the two-way pin -------------------------------------- +# 🔴 FILESYSTEM DISCOVERY, NOT `git ls-files`. The flake check builds from a +# `cp -r ${./.}` store copy with NO `.git`, so a git-based discovery would find +# nothing in the exact tier that CI runs. +# +# 🔴 AND BASH GLOBSTAR, NOT `find`. Three different `find`s are reachable from +# this repo — busybox under bash, `bfs` under the interactive zsh, GNU findutils +# in the nix sandbox — and they do not agree on `-printf`. Globstar is a bash +# builtin, so it depends on no external binary and behaves identically in every +# tier. +shopt -s globstar nullglob + +discovered=() +for f in "$GO_MODULE"/**/*_test.go; do + d="$(dirname "$f")" + rel="${d#"$GO_MODULE"/}" + case " ${discovered[*]-} " in *" $rel "*) ;; *) discovered+=("$rel") ;; esac +done + +pinned=() +for entry in "${PACKAGES[@]}"; do pinned+=("${entry%%|*}"); done + +guard_failed=0 +for d in "${discovered[@]-}"; do + case " ${pinned[*]} " in + *" $d "*) ;; + *) echo "run-go-tests: FATAL — package '$d' has tests but is NOT in PACKAGES." >&2 + echo " A new package swept in under the global total is a package nobody gave a floor." >&2 + guard_failed=1 ;; + esac +done +for d in "${pinned[@]}"; do + case " ${discovered[*]-} " in + *" $d "*) ;; + *) echo "run-go-tests: FATAL — pinned package '$d' has NO test files." >&2 + echo " The suite vanished, was renamed, or the module moved." >&2 + guard_failed=1 ;; + esac +done +# 🔴 POSITIVE CONTROL ON DISCOVERY ITSELF. An empty discovery satisfies the +# first loop vacuously, and the second loop's failures would then read as "the +# packages were deleted" rather than "the glob is wrong". +if [ "${#discovered[@]}" -eq 0 ]; then + echo "run-go-tests: FATAL — discovery found ZERO packages with tests under $GO_MODULE." >&2 + echo " That is a broken glob or a moved module, not an empty suite." >&2 + guard_failed=1 +fi +echo "go packages: discovered=${#discovered[@]} pinned=${#pinned[@]}" + +if [ "$CHECK_PACKAGES_ONLY" -eq 1 ]; then + # 🔴 `SCOPE: NONE`, NEVER FULL. This path validates the pin and exits in + # milliseconds having run zero tests; a `FULL` + `PASS` pair here is the + # full-gate-shaped claim off a run that tested nothing. + SCOPE_STATE="NONE" + SCOPE_DETAIL="--check-packages validated the pin only, no tests run" + [ "$guard_failed" -eq 0 ] || exit 4 + exit 0 +fi +[ "$guard_failed" -eq 0 ] || { SCOPE_STATE="PARTIAL" + SCOPE_DETAIL="the package pin tripped before any test ran"; exit 4; } + +# --- the toolchain precondition ------------------------------------------------ +# 🔴 A MISSING TOOLCHAIN IS EXIT 3, NEVER A PASS. `go test` absent means this +# tier measured nothing; reporting that as success is how a gate goes green over +# an untested language. +if ! command -v go >/dev/null 2>&1; then + echo "run-go-tests: FATAL — \`go\` is not on PATH." >&2 + echo " Enter the gate toolchain: nix develop $ROOT" >&2 + SCOPE_STATE="NONE"; SCOPE_DETAIL="go is not on PATH, no tests run" + exit 3 +fi +echo "go: $(go version)" + +# 🔴 THE MODULE CACHE MUST BE WRITABLE. In the nix sandbox `$HOME` is not, and +# `go test` then fails with a message about the cache rather than about the +# code — a red that reads like a broken change. The flake check sets these; this +# is the dev-host fallback. +export GOFLAGS="${GOFLAGS:--mod=mod}" +export GOCACHE="${GOCACHE:-$(mktemp -d)/go-build}" + +# --- run, and COUNT ------------------------------------------------------------ +total_pass=0 +total_fail=0 +total_skip=0 +failed_pkgs=() + +for entry in "${PACKAGES[@]}"; do + pkg="${entry%%|*}" + floor="${entry##*|}" + json="$(mktemp)" + + # 🔴 `-json`, AND THE EXIT CODE IS NOT WHAT IS READ. `go test` exits 0 over a + # package with no test files; the per-test Action records are what say + # whether anything ran. + ( cd "$GO_MODULE" && go test -json -count=1 "./$pkg" ) > "$json" 2>&1 + rc=$? + + # 🔴 ORDER-INDEPENDENT, AND THIS TOOK TWO MEASURED FAILURES TO GET RIGHT. + # Parsing a tool's output makes its FORMAT a dependency nobody pinned, and + # "no matches" means "possibly the wrong pattern", never "nothing there". + # + # 1. The first version grepped `'"Action":"pass","Test":'`. Go's encoder + # emits Time, Action, PACKAGE, Test — so it matched nothing and every + # package counted 0 tests, on the dev host. + # 2. The second allowed for that with `'"Action":"pass".*"Test":"'`. It + # worked on the dev host's go 1.25.14 and matched NOTHING in the nix + # sandbox, whose go is 1.26.7 — a DIFFERENT field order, in the tier + # whose blind spots are supposed to differ from the dev host's. + # + # Both times the per-package FLOOR is what caught it. Without a floor this + # tier would have printed `RESULT: PASS` having counted nothing — precisely + # the failure a floor exists for, and a second reason not to trust a zero. + # + # Two greps, so neither field's POSITION matters — only that both tokens are + # on the line. `-c` on the second counts what the first let through. + p=$(grep '"Action":"pass"' "$json" 2>/dev/null | grep -c '"Test":"' || true) + f=$(grep '"Action":"fail"' "$json" 2>/dev/null | grep -c '"Test":"' || true) + s=$(grep '"Action":"skip"' "$json" 2>/dev/null | grep -c '"Test":"' || true) + p=${p:-0}; f=${f:-0}; s=${s:-0} + ran=$((p + f + s)) + + total_pass=$((total_pass + p)) + total_fail=$((total_fail + f)) + total_skip=$((total_skip + s)) + + status="ok" + if [ "$f" -gt 0 ] || [ "$rc" -ne 0 ]; then + status="FAIL" + failed_pkgs+=("$pkg") + # Print the failing detail — a summary with no failure text is unactionable. + # + # 🔴 BOTH STREAMS, AND THE NON-JSON ONE IS THE IMPORTANT HALF. `go test` + # writes per-test results as JSON records but writes COMPILE errors to its + # own stderr as PLAIN TEXT. The first version of this block grepped the JSON + # records only, so a package that failed to BUILD reported the single line + # `FAIL [build failed]` and the actual error — file, line, symbol — + # was discarded. Measured in the nix sandbox, where four of five packages + # failed to build and the log said nothing whatsoever about why. + grep -v '^{' "$json" | head -40 >&2 + # 🔴 `build-output` TOO, AND THAT IS THE ARM THAT WAS MISSING. A package + # that fails to COMPILE emits its compiler error as a `build-output` record + # and nothing else — no `--- FAIL`, no `panic:`, and nothing outside the + # JSON for `grep -v '^{'` to catch. MEASURED in the nix sandbox: four of + # five packages failed with `cgo: C compiler "gcc" not found` and this + # runner printed not one word of it, so three rebuild cycles went into + # guessing. A failure report that omits the failure is not a report. + grep -E '"Action":"(build-output|build-fail)"' "$json" | head -20 >&2 + grep '"Action":"output"' "$json" | grep -E '(--- FAIL|panic:)' | head -40 >&2 + fi + if [ "$ran" -lt "$floor" ]; then + echo "run-go-tests: FATAL — $pkg ran $ran tests, floor is $floor." >&2 + echo " A package that collects fewer tests than its floor is a package that" >&2 + echo " stopped testing something, not a package that got smaller." >&2 + status="FLOOR" + failed_pkgs+=("$pkg") + fi + printf ' %-6s %-24s pass=%-4d fail=%-3d skip=%-3d (floor %d)\n' \ + "$status" "$pkg" "$p" "$f" "$s" "$floor" + rm -f "$json" +done + +global_floor=0 +for entry in "${PACKAGES[@]}"; do global_floor=$((global_floor + ${entry##*|})); done +global_floor="${MIN_GO_TESTS:-$global_floor}" +ran_total=$((total_pass + total_fail + total_skip)) + +echo "TOTAL: pass=$total_pass fail=$total_fail skip=$total_skip ran=$ran_total (global floor $global_floor)" + +SCOPE_STATE="FULL" +SCOPE_DETAIL="every pinned Go package, always — this runner has no selection flag" + +# 🔴 SKIPS ARE A FINDING, NOT A DETAIL. `internal/ui`'s palette guard skips when +# it cannot find a repo checkout above the module — correct inside the +# Go-module-only `buildGoModule` sandbox, and WRONG here, where this runner +# always runs from a checkout. A budget of zero is what makes a silent skip +# visible instead of reading exactly like a pass. +max_skips="${MAX_GO_SKIPS:-0}" +if [ "$total_skip" -gt "$max_skips" ]; then + echo "run-go-tests: FATAL — $total_skip test(s) SKIPPED, budget is $max_skips." >&2 + echo " A test that skips itself reads exactly like a passing one. Either the" >&2 + echo " skip is legitimate (raise MAX_GO_SKIPS and say why, in the commit) or" >&2 + echo " something it needs is missing from this environment." >&2 + exit 4 +fi + +if [ "$ran_total" -lt "$global_floor" ]; then + echo "run-go-tests: FATAL — ran $ran_total tests, global floor is $global_floor." >&2 + exit 4 +fi +if [ "${#failed_pkgs[@]}" -gt 0 ]; then + echo "run-go-tests: FAILED packages: ${failed_pkgs[*]}" >&2 + exit 1 +fi +exit 0 diff --git a/scripts/testlib/gitenv.py b/scripts/testlib/gitenv.py index 6ac7bf1a2..397166962 100644 --- a/scripts/testlib/gitenv.py +++ b/scripts/testlib/gitenv.py @@ -202,15 +202,15 @@ # --------------------------------------------------------------------------- # # 1. THE LEDGER: what redirects git at a repository # --------------------------------------------------------------------------- # -# 🔴 ORDERED and EXACT. Each of the SIX shell files above spells the same names -# in a `DEVRC_GIT_REPO_POINTERS` array — the four runners have to, because the +# 🔴 ORDERED and EXACT. Each of the SEVEN shell files above spells the same names +# in a `DEVRC_GIT_REPO_POINTERS` array — the five runners have to, because the # non-pytest targets (HOOK_TESTS, SHELL_TESTS, the node tier) never load a pytest # plugin, and because an inherited GIT_DIR corrupts each runner's ROOT resolution # before any Python runs; `commit.sh` has to because no test tier reaches it at # all, and `claim-work.sh` because an exported GIT_DIR BEATS `-C` and would send # its throwaway-repo setup at the operator's repository instead. # `test_git_repo_isolation.py::test_the_shell_and_python_pointer_ledgers_agree` is -# parametrised over all SIX (`POINTER_CLEARERS`) and fails if any diverges from +# parametrised over all SEVEN (`POINTER_CLEARERS`) and fails if any diverges from # this tuple in EITHER direction. Adding a name here without adding it there would # leave those targets unprotected while this docstring claimed otherwise. # diff --git a/scripts/tests/test_gate_reexec.py b/scripts/tests/test_gate_reexec.py index c061f12d7..392f67756 100644 --- a/scripts/tests/test_gate_reexec.py +++ b/scripts/tests/test_gate_reexec.py @@ -69,6 +69,18 @@ # none for the two variables the refusal was added to catch. "DEVRC_TARGETS", "MIN_TESTS", + # 🔴 THE GO TIER'S THREE, and they arrived with the SAME blindness one turn + # deeper. The refusal loop that names them was wrapped onto a second line + # with a backslash, and the harvester below matched `for … in … ; do` on ONE + # line only — so it stopped seeing the ENTIRE list, including the two above + # that the comment right there says it was written to catch. + # `DEVRC_GATE_GO_RUNNER` is read a second time as `${DEVRC_GATE_GO_RUNNER:-…}` + # and so stayed visible; `MIN_GO_TESTS` and `MAX_GO_SKIPS` are named NOWHERE + # else in gate.sh except comments, so they were invisible outright. The + # asymmetry is the only reason this failed loudly instead of silently. + "DEVRC_GATE_GO_RUNNER", + "MIN_GO_TESTS", + "MAX_GO_SKIPS", ) @@ -304,7 +316,7 @@ def test_a_relative_path_invocation_survives_the_re_exec(tmp_path): assert proc.returncode == 0, combined -def _env_vars_gate_sh_reads() -> set[str]: +def _env_vars_gate_sh_reads(path: Path = None) -> set[str]: """Every environment variable gate.sh reads, DERIVED FROM THE SOURCE. 🔴 A LITERAL LIST HERE IS THE BUG THIS FUNCTION REPLACES. The predecessor of @@ -321,9 +333,19 @@ def _env_vars_gate_sh_reads() -> set[str]: is correct because both halves are real. Comment lines are excluded, so documenting a variable cannot satisfy the test that checks it is documented. """ - lines = GATE.read_text().splitlines() + lines = (path or GATE).read_text().splitlines() body_start = next(i for i in range(1, len(lines)) if not lines[i].startswith("#")) body = "\n".join(l for l in lines[body_start:] if not l.lstrip().startswith("#")) + # 🔴 A BACKSLASH CONTINUATION IS ONE SHELL LINE AND MUST BE ONE LINE HERE. + # The `for … in … ; do` harvest below anchors on `^` and cannot span a + # newline, so a list wrapped across two lines matched NOTHING and every name + # in it went unharvested — silently, because an empty match set and "this + # loop reads no environment variables" are the same observation. That is the + # reassuring zero this whole function's docstring is about, and it happened: + # the go tier's refusal loop wrapped, and `DEVRC_TARGETS`/`MIN_TESTS` + # disappeared from the ledger a second time. Join them before scanning so + # the harvester sees what the SHELL sees. + body = re.sub(r"\\\n[ \t]*", " ", body) first_read: dict[str, int] = {} for m in re.finditer(r"\$\{([A-Z][A-Z0-9_]*)(?::-|:\+|:=|:\?|-|\+)", body): first_read.setdefault(m.group(1), m.start()) @@ -356,6 +378,52 @@ def test_the_env_var_derivation_can_actually_see_a_variable(): assert "LOG_DIR" not in found, sorted(found) +def test_the_derivation_sees_a_refusal_list_WRAPPED_ACROSS_LINES(tmp_path): + """🔴 THE CONTROL THAT WOULD HAVE CAUGHT IT, AND IT IS HERMETIC ON PURPOSE. + + The harvester anchors its `for … in … ; do` match on `^`, so a list wrapped + with a backslash matched nothing and EVERY name in it went unharvested. That + is invisible by construction: "the regex found no loop" and "the loop reads + nothing" produce the identical empty set, so the two-way pin reported full + coverage over a hole. It happened twice — once for `DEVRC_TARGETS`/ + `MIN_TESTS` through indirect expansion, then again for the whole list the + moment the go tier's three names made it too long for one line. + + Pinned against a SYNTHETIC script rather than against gate.sh, so gate.sh + reformatting its own loop can never quietly retire this control. `WRAPPED_C` + is reachable ONLY through the continuation — a harvester that stops at the + backslash returns the first two and looks fine. + """ + # 🔴 `write_exec` OWNS THE SHEBANG. A test that writes its own is + # what `test_runtime_shebangs.py` forbids: `patchShebangs` fixes the source + # tree and cannot reach a file written at RUNTIME. This script is parsed + # rather than executed, but the guard is on the WRITE, not the run — and + # `_env_vars_gate_sh_reads` skips line 0, so the shebang must still be there. + script = write_exec( + tmp_path / "wrapped.sh", + "set -eu\n" + 'echo "${VISIBLE_ONE:-x}"\n' + "# WRAPPED_D documented here and read NOWHERE — the negative control\n" + "for _v in WRAPPED_A WRAPPED_B \\\n" + " WRAPPED_C; do\n" + ' [ -n "${!_v+x}" ] && exit 2\n' + "done\n" + ) + found = _env_vars_gate_sh_reads(script) + assert "VISIBLE_ONE" in found, ( + "the derivation could not see a plain expansion in the synthetic " + f"script — the control itself is broken. found={sorted(found)}") + for name in ("WRAPPED_A", "WRAPPED_B", "WRAPPED_C"): + assert name in found, ( + f"{name} was not harvested from a backslash-continued refusal list. " + "Every name after the continuation is unscrubbed and unpinned, and " + f"nothing says so. found={sorted(found)}") + # NEGATIVE CONTROL on this control: a name only ever mentioned in a COMMENT + # is not a read, continuation or not — documenting a variable must not be + # able to satisfy the guard that checks it is scrubbed. + assert "WRAPPED_D" not in found, sorted(found) + + def test_the_help_text_documents_every_env_var_the_script_reads(): """--help used to be a hardcoded `sed 2,70p` and silently truncated as the header grew. A flag documented nowhere the operator looks is a flag that diff --git a/scripts/tests/test_git_repo_isolation.py b/scripts/tests/test_git_repo_isolation.py index fa43458a9..97a03d8ed 100644 --- a/scripts/tests/test_git_repo_isolation.py +++ b/scripts/tests/test_git_repo_isolation.py @@ -99,8 +99,8 @@ CONFTEST = SCRIPTS / "tests" / "conftest.py" # Every entry point that resolves a repo root with -# `git rev-parse --show-toplevel` and then runs tests. All four carry GUARD 9's -# `unset`, and all four must run it BEFORE that line — see +# `git rev-parse --show-toplevel` and then runs tests. All five carry GUARD 9's +# `unset`, and all five must run it BEFORE that line — see # test_every_runner_clears_BEFORE_it_resolves_its_root. # # 🔴 `githooks/tests-on-push.sh` IS ONE OF THEM and #683 left it out, while this @@ -108,7 +108,7 @@ # the `git push` path — the path the incident travelled — and resolves # `REPO_ROOT` with exactly the vulnerable expression. # -# 🔴 FIVE SPELLINGS RATHER THAN ONE SOURCED FILE, and the reason is measured, +# 🔴 SEVEN SPELLINGS RATHER THAN ONE SOURCED FILE, and the reason is measured, # not aesthetic: `testlib/runner_patch.py` writes a patched COPY of # `run-tests.sh` into a tmp dir and about fifteen tests drive that copy. A copy # cannot source a sibling `lib/` that was never copied with it — the first @@ -117,6 +117,7 @@ # owned once, in `testlib/gitenv.py`, and every spelling is pinned to them here. RUNNERS = (SCRIPTS / "run-tests.sh", SCRIPTS / "run-node-tests.sh", + SCRIPTS / "run-go-tests.sh", SCRIPTS / "gate.sh", ROOT / "githooks" / "tests-on-push.sh") @@ -410,7 +411,7 @@ def test_the_shell_and_python_pointer_ledgers_agree(runner): resolvable only by luck; a name in a runner and not in Python leaves a bare `pytest` exposed. Either way the guard claims coverage it does not have. - `commit.sh` is in this list for a different reason from the four runners: + `commit.sh` is in this list for a different reason from the five runners: nothing there resolves a ROOT, but it is the file that COMMITS, so a name missing from its copy is a repository somebody else's content can land in. """ diff --git a/scripts/tests/test_main_green_check.py b/scripts/tests/test_main_green_check.py index 62004f9ce..58f0cb712 100644 --- a/scripts/tests/test_main_green_check.py +++ b/scripts/tests/test_main_green_check.py @@ -146,7 +146,7 @@ def test_POSITIVE_CONTROL_the_harness_can_observe_a_pass(world): r = _run(world, stub) assert r.returncode == RC_GREEN, r.stdout + r.stderr assert "GREEN" in r.stdout - assert _calls(world) == ["pytests", "nodetests"], "both tiers must run" + assert _calls(world) == ["pytests", "nodetests", "gotests"], "every tier must run" # ── the arms ───────────────────────────────────────────────────────────────── @@ -176,12 +176,12 @@ def test_the_retry_does_NOT_loop_until_green(world): permanently-broken main as clean, which inverts its entire purpose. Measured mechanically: with a gate that fails forever, the stub must be - invoked exactly 4 times (2 tiers x 2 attempts) and no more. + invoked exactly 6 times (3 tiers x 2 attempts) and no more. """ stub = _stub(world, 'echo "RESULT: FAIL (exit=1)"; exit 1\n') r = _run(world, stub) assert r.returncode == RC_RED - assert len(_calls(world)) == 4, _calls(world) + assert len(_calls(world)) == 6, _calls(world) def test_a_green_verdict_is_MEMOIZED_on_the_sha_and_reruns_nothing(world): @@ -203,7 +203,7 @@ def test_the_memo_is_INVALIDATED_when_main_moves(world): _advance_main(world) r = _run(world, stub) assert r.returncode == RC_GREEN - assert len(_calls(world)) == before + 2, "a moved main must re-run both tiers" + assert len(_calls(world)) == before + 3, "a moved main must re-run every tier" def test_force_overrides_the_memo(world): @@ -212,7 +212,7 @@ def test_force_overrides_the_memo(world): before = len(_calls(world)) r = _run(world, stub, "--force") assert r.returncode == RC_GREEN - assert len(_calls(world)) == before + 2 + assert len(_calls(world)) == before + 3 def test_a_RED_main_that_has_not_moved_still_reports_RED(world): @@ -689,7 +689,7 @@ def test_the_production_path_builds_the_two_sandbox_derivations(): "measured to set NIX_CONFIG to the bare word `experimental-features` " "and make every nix invocation hard-error.") tiers = re.search(r'for tier in ([a-z ]+); do', src) - assert tiers and tiers.group(1).split() == ["pytests", "nodetests"], ( + assert tiers and tiers.group(1).split() == ["pytests", "nodetests", "gotests"], ( "the tier list moved: %r" % (tiers.group(1) if tiers else None)) assert src.count('build "$CLONE') == 1, ( "more than one nix build invocation — the one-at-a-time property is " diff --git a/scripts/tests/test_mention_review.py b/scripts/tests/test_mention_review.py new file mode 100644 index 000000000..a0a33ccb4 --- /dev/null +++ b/scripts/tests/test_mention_review.py @@ -0,0 +1,384 @@ +"""Guards for `mention-review` — the Phase-1 Go PR-review TUI — and for the +THIRD GATE TIER it brought with it. + +🔴 WHY ANY OF THIS IS IN PYTHON AT ALL. The Go suite tests the program. These +test the SEAMS the Go suite structurally cannot see: whether the tier is wired +into the gate, whether the packaging can state truthfully what it built, and +whether the click path was left alone. Each of those lives in a file the Go +compiler never reads. + +🔴 AND THE SEAMS ARE THE DEFECT CLASS. A Go package and a Nix derivation can +each be hermetically correct and broken TOGETHER — a tier that exists and is run +by nobody, a version pattern that matches nothing, a `doCheck` that turns a red +test into a skipped host. Every guard below pins a RELATIONSHIP between two +files, not a property of one. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +PKG = ROOT / "nix" / "pkgs" / "tools" / "mention-review" +PKG_NIX = PKG / "default.nix" +VERSION_GO = PKG / "src" / "cmd" / "mention-review" / "version.go" +GO_RUNNER = ROOT / "scripts" / "run-go-tests.sh" +GATE = ROOT / "scripts" / "gate.sh" +FLAKE = ROOT / "flake.nix" +MENTION_OPEN = ROOT / "scripts" / "mention-open.py" + + +# --------------------------------------------------------------------------- # +# The version contract (§9.1) +# --------------------------------------------------------------------------- # + +def test_the_nix_version_pattern_matches_exactly_one_line_of_the_go_source(): + """🔴 THE PATTERN AND THE SOURCE ARE TWO FILES THAT MUST AGREE, AND NOTHING + ELSE MAKES THEM. + + `default.nix` reads the version out of `version.go` with a regex. Zero + matches or two matches both yield `null`, which switches the package OFF — + deliberately, because a derivation that cannot state truthfully what it is + building must not be installed. That is the right runtime behaviour and a + terrible thing to discover on a host: the failure is `mention-review: + command not found`, hours later, on the machine that took the switch. + + This turns it into a test failure instead. It reads the pattern FROM the Nix + file rather than restating it — a second copy of a regex is how a regex and + its subject drift apart. + """ + nix_src = PKG_NIX.read_text(encoding="utf-8") + m = re.search(r'versionPattern\s*=\s*"(.*?)";', nix_src) + assert m, "default.nix no longer declares a versionPattern" + + # Nix string escaping: `\"` in the Nix literal is a plain quote in the regex. + pattern = m.group(1).replace('\\"', '"') + # `builtins.match` is ANCHORED at both ends; Python's `match` anchors only + # the start, so the trailing `.*` in the pattern plus `fullmatch` reproduces + # Nix's semantics rather than approximating them. + rx = re.compile(pattern) + + lines = VERSION_GO.read_text(encoding="utf-8").split("\n") + hits = [ln for ln in lines if rx.fullmatch(ln)] + assert len(hits) == 1, ( + f"the Nix version pattern matches {len(hits)} line(s) of version.go, " + f"and it must match EXACTLY ONE. Zero or two both make `available = " + f"false`, so the package is silently not installed.\n" + f"pattern: {pattern!r}\nmatches: {hits}" + ) + captured = rx.fullmatch(hits[0]).group(1) + assert captured, "the pattern matched but captured an empty version" + assert re.fullmatch(r"\d+\.\d+\.\d+", captured), ( + f"version {captured!r} is not x.y.z — the store path label and the " + f"`--version` output both carry this string" + ) + + +def test_the_version_is_not_spelled_in_the_nix_file(): + """🔴 THE FAILURE THIS WHOLE MECHANISM EXISTS FOR IS A LITERAL. + + `clawgatectl.nix` is in its current shape because a hand-maintained + `version = "x.y.z"` stamped 0.7.95 onto a binary built from 0.7.87 source, + producing a CLI that printed help and exited 0 for a subcommand it did not + have. A literal creeping back into this file recreates that exactly, and it + would look perfectly ordinary in review. + """ + nix_src = PKG_NIX.read_text(encoding="utf-8") + body = re.sub(r"#[^\n]*", "", nix_src) # comments discuss literals on purpose + bad = re.findall(r'version\s*=\s*"\d', body) + assert not bad, ( + f"default.nix spells a version LITERAL ({bad}); it must be derived from " + f"the Go source by `parsedVersion`" + ) + assert "parsedVersion" in body, "the derived version binding is gone" + + +def test_the_deploy_derivation_disables_doCheck(): + """🔴 `doCheck = false` IS A SAFETY PROPERTY, NOT A SPEED ONE. + + Verified in the pinned nixpkgs rather than assumed: + `pkgs/build-support/go/module.nix` defaults `doCheck` to TRUE and its check + phase runs `buildGoDir test` over every test directory. On a package in + `home.packages`, a red Go test therefore FAILS a `home-manager switch` — + which `ship.sh` reports as a SKIPPED host, the failure mode this repo's + CLAUDE.md documents as silently stopping all future delivery to that + machine. A failing test must cost a red gate leg, never a dead host. + """ + body = re.sub(r"#[^\n]*", "", PKG_NIX.read_text(encoding="utf-8")) + assert re.search(r"doCheck\s*=\s*false\s*;", body), ( + "the deploy derivation does not set `doCheck = false` — a red Go test " + "can now fail a home-manager switch" + ) + + +# --------------------------------------------------------------------------- # +# The third gate tier +# --------------------------------------------------------------------------- # + +def test_the_go_tier_is_wired_into_every_place_that_enumerates_tiers(): + """🔴 A TIER THAT EXISTS AND IS RUN BY NOBODY IS WORSE THAN NO TIER — it + reads as coverage while providing none. + + Go was a THIRD tier added to a gate built for two, and that touches several + files that each independently enumerate the legs. This pins the set, so a + future tier cannot be half-added the way this one could have been. + """ + assert GO_RUNNER.exists(), "scripts/run-go-tests.sh is missing" + + gate = GATE.read_text(encoding="utf-8") + assert "GO_RUNNER" in gate, "gate.sh does not know about a go runner" + assert re.search(r'"\$TIER"\s*=\s*"go"', gate), ( + "gate.sh has no `--tier go` branch" + ) + assert "pytest|node|go|all" in gate, ( + "gate.sh's --tier validation does not accept `go`" + ) + + flake = FLAKE.read_text(encoding="utf-8") + assert re.search(r"^\s*gotests\s*=", flake, re.M), ( + "flake.nix has no `checks.gotests` output — CI enumerates legs by " + "building `.#checks.x86_64-linux.${LEG}`, so a missing output is a " + "tier CI can never run" + ) + assert "run-go-tests.sh" in flake, ( + "checks.gotests does not invoke the runner" + ) + + +def test_the_go_runner_env_overrides_are_refused_by_the_gate(): + """🔴 THE ASYMMETRIC-REFUSAL DEFECT, NOT REPEATED ONE TIER OVER. + + `gate.sh` refuses to run when a variable that changes WHAT RUNS is set in + its environment, because `GATE: RESULT=PASS` would still read as a full + verdict. That list was once `DEVRC_TARGETS` alone while three other + variables had the same power — "an over-broad claim beside an asymmetric + refusal, which reads as coverage and provides none", in gate.sh's own words. + + The go tier adds three more levers. Each must be in the list. + """ + gate = GATE.read_text(encoding="utf-8") + block = re.search(r"_gate_ambient=\(\)(.*?)done", gate, re.S) + assert block, "gate.sh's ambient-variable refusal loop has moved" + listed = block.group(1) + for var in ("DEVRC_GATE_GO_RUNNER", "MIN_GO_TESTS", "MAX_GO_SKIPS"): + assert var in listed, ( + f"{var} changes what the go tier runs or accepts, and gate.sh does " + f"not refuse an ambient value for it" + ) + + +def test_the_runner_pins_its_packages_two_way(): + """The runner's own GUARD 2, driven as a subprocess. + + 🔴 IT IS RUN, NOT READ. A test that grepped for the word `PACKAGES` would + pass over a loop that had stopped comparing anything. `--check-packages` + exists precisely so this can be exercised cheaply: no `go`, no tests. + """ + proc = subprocess.run( + ["bash", str(GO_RUNNER), "--check-packages", str(ROOT)], + capture_output=True, text=True, timeout=120, + ) + out = proc.stdout + proc.stderr + assert proc.returncode == 0, f"the package pin is broken:\n{out}" + + m = re.search(r"discovered=(\d+) pinned=(\d+)", out) + assert m, f"the runner no longer reports its counts:\n{out}" + discovered, pinned = int(m.group(1)), int(m.group(2)) + # 🔴 POSITIVE CONTROL. `discovered == pinned` is satisfied by 0 == 0, which + # is exactly what a broken glob produces — and it would read as a clean + # two-way pin. + assert discovered > 0, "discovery found ZERO packages — a broken glob, not an empty suite" + assert discovered == pinned, f"discovered={discovered} pinned={pinned}" + + # 🔴 AND IT REPORTS `SCOPE: NONE`, NEVER FULL. A zero-test invocation that + # printed `SCOPE: FULL` + `RESULT: PASS` is the full-gate-shaped pair off a + # run that tested nothing — the shape `gate.sh` exits 91 for. + assert "SCOPE: NONE" in out, ( + f"--check-packages ran zero tests and did not say SCOPE: NONE:\n{out}" + ) + + +def test_a_pinned_package_that_vanishes_is_caught(tmp_path): + """🔴 MUTATION CONTROL ON GUARD 2, run rather than asserted. + + The two-way pin is only worth having if it can go RED. This copies the + runner, adds a package nobody has, and watches it fail with THIS guard's own + message — not with some other arm's. + """ + mutated = tmp_path / "run-go-tests.sh" + src = GO_RUNNER.read_text(encoding="utf-8") + assert '"internal/udiff|11"' in src, "the PACKAGES table has been reshaped" + mutated.write_text( + src.replace('"internal/udiff|11"', + '"internal/udiff|11"\n "internal/ghost|1"'), + encoding="utf-8", + ) + proc = subprocess.run( + ["bash", str(mutated), "--check-packages", str(ROOT)], + capture_output=True, text=True, timeout=120, + ) + out = proc.stdout + proc.stderr + assert proc.returncode != 0, f"a pinned package with no tests passed:\n{out}" + assert "pinned package 'internal/ghost' has NO test files" in out, ( + f"the failure did not come from THIS guard — a kill by a different " + f"arm is green for the wrong reason and stays green with this arm " + f"deleted.\n{out}" + ) + + +def test_an_undiscovered_package_with_tests_is_caught(tmp_path): + """The OTHER direction of the same pin: a package with tests that nobody + gave a floor. Without this arm a new package is swept in under the global + total and its own collapse becomes invisible. + """ + mutated = tmp_path / "run-go-tests.sh" + src = GO_RUNNER.read_text(encoding="utf-8") + assert ' "internal/udiff|11"\n' in src, "the PACKAGES table has been reshaped" + mutated.write_text(src.replace(' "internal/udiff|11"\n', ""), encoding="utf-8") + + proc = subprocess.run( + ["bash", str(mutated), "--check-packages", str(ROOT)], + capture_output=True, text=True, timeout=120, + ) + out = proc.stdout + proc.stderr + assert proc.returncode != 0, f"an unpinned package with tests passed:\n{out}" + assert "has tests but is NOT in PACKAGES" in out, ( + f"the failure did not come from THIS guard:\n{out}" + ) + + +def test_the_runner_refuses_rather_than_passing_when_go_is_missing(tmp_path): + """🔴 A MISSING TOOLCHAIN IS NEVER A PASS. + + `go test` absent means this tier measured nothing. Reporting that as success + is how a gate goes green over an untested language — the exact shape + `run-tests.sh`'s REQUIRED_TOOLS precondition exists for, and the reason it + exits 3 rather than skipping. + """ + # A PATH carrying everything the runner needs EXCEPT `go`. + # + # ⚠ THERE IS NO `/bin/bash` ON THIS HOST — it is NixOS, and the only + # guaranteed absolute path is `/bin/sh`. An earlier version of this test + # passed `executable="/bin/bash"` and died `FileNotFoundError: /bin/bash`, + # which reads like a broken runner rather than a broken test. + stub = tmp_path / "bin" + stub.mkdir() + tools = ("bash", "git", "grep", "mktemp", "dirname", "cat", "sed", "head", + "rm", "env", "uname", "tr", "sort", "wc") + resolved = 0 + for tool in tools: + which = subprocess.run(["bash", "-c", f"command -v {tool}"], + capture_output=True, text=True) + target = which.stdout.strip() + if which.returncode == 0 and target: + (stub / tool).symlink_to(target) + resolved += 1 + # 🔴 POSITIVE CONTROL ON THE STUB ITSELF, BOTH HALVES. `go` must be absent + # (or this proves nothing about a missing toolchain) AND the stub must + # actually carry tools (or the runner dies for want of `bash` and the exit + # code says nothing about `go`). + assert not (stub / "go").exists() + assert resolved >= 5, f"the stub PATH resolved only {resolved} tools" + + proc = subprocess.run( + ["bash", str(GO_RUNNER), str(ROOT)], + capture_output=True, text=True, timeout=180, + env={"PATH": str(stub), "HOME": str(tmp_path)}, + ) + out = proc.stdout + proc.stderr + assert proc.returncode == 3, ( + f"a missing `go` produced exit {proc.returncode}, want 3:\n{out}" + ) + assert "`go` is not on PATH" in out, out + # And it must NOT claim it ran everything. + assert "SCOPE: FULL" not in out, ( + f"a run that never started reported FULL scope:\n{out}" + ) + assert "RESULT: FAIL" in out, out + + +# --------------------------------------------------------------------------- # +# Phase 1 leaves the click path alone +# --------------------------------------------------------------------------- # + +def test_phase_1_does_NOT_flip_the_click_path_to_the_new_tui(): + """🔴 THE RETIREMENT IS A SEPARATE, LATER, REVERTABLE STEP. + + The proposal is explicit that `nvim-octo` ships until "the operator has used + the new TUI for a real review" — a condition nothing headless can close. So + `REVIEW_EXE` still names `nvim-octo`, and this pins that rather than leaving + it to be noticed. + + ⚠ THIS TEST IS EXPECTED TO BE DELETED, NOT EDITED, when the flip happens. + It is a guard on a PHASE, and a phase that ends takes its guard with it. + Editing it to accept `mention-review` would leave a test whose name says + Phase 1 asserting Phase 4. + """ + src = MENTION_OPEN.read_text(encoding="utf-8") + m = re.search(r'REVIEW_EXE\s*=\s*"([^"]+)"', src) + assert m, "mention-open.py no longer declares REVIEW_EXE" + assert m.group(1) == "nvim-octo", ( + f"REVIEW_EXE is {m.group(1)!r}. If the click path has been flipped to " + f"mention-review on purpose, DELETE this test — it guards Phase 1, " + f"which has ended." + ) + + +def test_the_alacritty_wrapper_does_not_yet_pin_the_new_tui(): + """The mirror of the test above, and it is enforced by a guard that already + exists rather than by this one. + + `test_mention_open.py::test_the_alacritty_wrapper_PATH_covers_every_ + executable_the_handler_spawns` pins that list TWO-WAY: a package the handler + does not spawn fails as "dead weight in the closure". So adding + `pkgs.mention-review` there before flipping `REVIEW_EXE` would already be + red. This asserts the current, consistent state so the pair is visible in + one place. + """ + nix_src = (ROOT / "nix" / "programs" / "alacritty" / "default.nix").read_text(encoding="utf-8") + m = re.search(r"makeBinPath\s*\[(.*?)\]", nix_src, re.S) + assert m, "the mentionOpen wrapper no longer calls lib.makeBinPath" + body = re.sub(r"#[^\n]*", "", m.group(1)) + assert "pkgs.nvim-octo" in body, "positive control: the wrapper DOES pin the review TUI" + assert "pkgs.mention-review" not in body, ( + "the wrapper pins pkgs.mention-review while REVIEW_EXE still spawns " + "nvim-octo — that is dead weight, and test_mention_open.py's two-way " + "ledger will say so too" + ) + + +# --------------------------------------------------------------------------- # +# The repo is PUBLIC +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("path", sorted( + p for p in (PKG / "src").rglob("*.go") +)) +def test_no_go_source_file_carries_a_real_repository_or_host(path: Path): + """🔴 THIS REPO IS PUBLIC, AND FIXTURES ARE NOT EXEMPT. + + Captured text — anyone's diffs, filenames, message bodies — must not land + here in any form. The Go fixtures use invented owners and repositories + (`gardenersguild/trowelcast`, `rivalorg/spadeworks`); this checks the one + real slug that legitimately appears in prose has not leaked into a FIXTURE, + and that no private-looking host has. + + ⚠ NARROW BY CONSTRUCTION. It cannot see a repository name it has never + heard of, so it is a tripwire rather than a proof — the content gates in + `test_no_captured_text.py` and `test_no_client_hostnames.py` own the real + ledgers. + """ + text = path.read_text(encoding="utf-8") + code = "\n".join( + ln for ln in text.split("\n") + if not ln.lstrip().startswith("//") + ) + # api.github.com is the one host this program legitimately talks to. + hosts = re.findall(r"https?://([A-Za-z0-9.-]+)", code) + allowed = {"api.github.com", "github.com"} + bad = sorted(set(hosts) - allowed) + assert not bad, f"{path.name} carries non-GitHub host(s): {bad}" diff --git a/scripts/tests/test_no_real_launchers.py b/scripts/tests/test_no_real_launchers.py index a09faebd5..b5b225041 100644 --- a/scripts/tests/test_no_real_launchers.py +++ b/scripts/tests/test_no_real_launchers.py @@ -1865,6 +1865,25 @@ def test_the_module_loader_scan_can_actually_find_something(tmp_path): "systemctl, notify-send, rofi, yad, alacritty, xdotool, i3-msg, openrgb, " "espanso, " "home-manager or nixos-rebuild"), + "test_mention_review.py": ( + '"PATH"' + ': str(stub)', + "the go tier's missing-toolchain guard. `scripts/run-go-tests.sh` exits " + "3 when `go` is not on PATH rather than reporting a pass, because a " + "missing toolchain means the tier measured NOTHING and calling that " + "success is how a gate goes green over an untested language. 🔴 " + "REPLACING is required to reach that branch at all: `go` is present on " + "the dev host AND in the sandbox (it is in `gateTools`), so no amount " + "of PREPENDING can make it unfindable, and a prepending version would " + "measure the environment instead of the runner. The directory is " + "CONSTRUCTED in tmp_path — one symlink per NAMED tool from an " + "enumerated list (bash, git, grep, mktemp, dirname, cat, sed, head, " + "rm, env, uname, tr, sort, wc) — and the test asserts BOTH halves of " + "its own control: that `go` is absent from it, and that at least five " + "tools resolved, so the runner does not simply die for want of `bash` " + "and produce an exit code that says nothing about `go`. No " + "HAZARD_VOCABULARY name is reachable through it: no systemd-run, " + "systemctl, notify-send, rofi, yad, alacritty, xdotool, i3-msg, " + "openrgb, espanso, home-manager or nixos-rebuild"), "test_rig_control.py": ( '"PATH"' + ': "/usr/bin/false"', "deliberately makes yad unfindable; /usr/bin/false holds no binaries, so "