# pivi — testing rules pivi drives an external Pi process over JSONL RPC and mutates live buffers. Both properties break the usual "require the module, assert side effects" pattern. Follow these rules; they are load-bearing, not style. Spec: `.system/specs/NVIM-SPEC-GMWDCMJZ-pivi-native-neovim-integration/index.md`. Research (Pi 0.85.1 / nvim 0.12 API facts): `.system/research/NVIM-RESEARCH-KZYYEFY_-pi-and-neovim-capabilities-for-pivi/index.md`. ## Test layers | Layer | Scope | Where | Runs | | --- | --- | --- | --- | | L1 pure | framing, delta assembly, status reducer, partial-arg parser, snapshots | `tests/bugabinga/pivi/*_spec.lua` | every `just test` | | L2 fake transport | `pivi.rpc` fed scripted frames | `tests/bugabinga/pivi/rpc_spec.lua` | every `just test` | | L3 editor effects | real buffers, extmarks, undo, qf/loclist, windows | `tests/bugabinga/pivi/editor/*_spec.lua` | every `just test` | | L4 driven nvim | interactive/async behavior in a live nvim | `tests/drive/*.lua` | on demand | | L5 Pi contract | real `pi --mode rpc` assumptions | `tests/contract/*_spec.lua` | opt-in only | | L6 rendering | actual TUI pixels/layout | WezTerm pane capture | rare, manual | Most pivi code belongs in L1. If a bug can only be caught in L4+, the logic is in the wrong place. ## Rules 1. **Keep the core pure.** Parsing, assembly, and state transitions are `fn(state, frame) -> state, effects`. No `vim.api` inside them. 2. **Inject the spawner.** `pivi.rpc` takes a `spawn` function; tests pass a fake. Never `rawset(vim, 'system', ...)` for pivi tests — pivi holds a long-lived process, and global monkeypatching leaks across specs. 3. **Frame tests must include hostile input:** records split at arbitrary byte boundaries, trailing `\r`, `U+2028`/`U+2029` inside JSON strings, interleaved responses and events, unknown event types, stderr noise, child exit mid-stream. 4. **Chunk-split fuzz is mandatory** for the decoder: replay one golden transcript under many random split points and assert an identical frame sequence. 5. **Never sleep.** Use `vim.wait(ms, predicate)` or an RPC roundtrip as a flush barrier. A bare `vim.wait(200)` in a committed test is a bug. 6. **Assert data, not screens,** except in L6. 7. **Prove scheduling discipline.** Editor-facing callbacks assert `not vim.in_fast_event()`; RPC output callbacks run in fast contexts and must defer. 8. **Ghost text must be provably inert:** feed incomplete tool arguments, assert `b:changedtick` is unchanged. 9. **Undo coherence is a test, not a hope:** apply an edit, press `u` once, assert full revert; force the undo-join refusal path and assert the edit survives. 10. **Namespace isolation is a test:** agent diagnostics/extmarks in pivi's own namespace, LSP namespaces untouched. 11. **Clean up in `after_each`:** delete created buffers, clear namespaces, stop fake/real children, `package.loaded['bugabinga.pivi.*'] = nil`. 12. **Consumers degrade:** at least one spec asserts a statusline/consumer works with pivi never loaded, and that status evaluation performs no RPC. 13. **Fixtures come from reality.** Golden JSONL under `tests/fixtures/pivi/` is recorded from a real `pi --mode rpc` run with the user's own Pi configuration loaded, never hand-written from memory. Read them through `tests/helpers/pivi_fixture.lua`. | Fixture | Contents | | --- | --- | | `rpc-no-llm.jsonl` | responses only, no model call | | `rpc-provider-error.jsonl` | a real failed run, provider error on `message_end` | | `rpc-text-stream.jsonl` | a streamed answer, thinking and text deltas, extension traffic | | `rpc-tool-stream.jsonl` | a streamed tool call, partial argument deltas, tool execution, extension traffic | Record a new one cheaply against a local model, never a metered one: ``` { printf '%s\n' '{"id":"p1","type":"prompt","message":"..."}'; sleep 40; } | pi --mode rpc --no-session --thinking low --model hetzner/Qwen3.8-27B > fixture.jsonl ``` 14. **Bring your own Pi.** pivi adds `-e ` and nothing else to the user's Pi. Never pass `--no-extensions`, `--no-skills`, `--no-prompt-templates`, `--no-context-files`, `--no-builtin-tools`, or `--system-prompt`, in product code, in contract tests, or while recording fixtures. `:Pi help` isolates session state only: `--no-session`, `--no-tools`, `--append-system-prompt`. Specs assert this on both command builders; keep them. A run started without the user's configuration proves nothing, and silently changes which providers exist. 15. **Help context stays small and live.** `:Pi help` attaches only the visible region of the current window plus layout, selection, recent messages, and the last error; never whole buffers. Depth is fetched on demand: `nvim_help` for Neovim and plugin documentation, `pi-knowledge` for Pi, the `pivi` skill for pivi, `nvim_state` and `nvim_lua` for live values. Never answer a Neovim documentation question from model memory in this codebase: `nvim_help` resolves against the running version's runtime path, including plugin docs. Help is read-only by construction; keep mutating tools out of its allowlist. 16. **Edits stay proportional.** Never rewrite a whole buffer for a local change: `edit.replace` rewrites only the spanned lines and `edit.set_content` diffs first. A whole-buffer replacement marks every line as agent work, destroys marks and folds, and makes the trail useless. Assert the trail covers only the changed lines. 17. **The trail belongs to the agent until the user edits.** `activity.touch` records the agent-owned revision; a later foreign change clears the trail. Anything the agent's own operation triggers, including a write, must call `touch` again, or an unrelated plugin reacting to the write wipes the trail. A revision recorded before the operation finishes is not enough; re-record after the event loop settles. 18. **The transcript buffer holds message text, nothing else.** Framing, labels, quoting and tool markers are extmark decoration in `bugabinga.pivi.render`. A spec must assert both that the framing is absent from the text and that it is present as decoration; otherwise the next change silently reintroduces prefixes. 19. **Entry bounds are extmark-backed.** `bugabinga.pivi.entry` re-anchors on every write, and an entry ends at its last non-empty row because appending a complete line always leaves a trailing empty row behind. Never address an entry by a remembered line number. 20. **Never force a parse per streamed delta.** Markdown rendering is `vim.treesitter.start` and stays redraw-driven; a forced parse per delta measured roughly five hundred times slower. Keep the streaming budget spec in `tests/bugabinga/pivi/editor/render_spec.lua`. 21. **Never invent an outcome.** `bugabinga.pivi.tool` derives everything from what a tool reported in its result details, and a tool that reported nothing gets no outcome, no language, and no change. Pi reports a change on every `edit`, progress on `bash`, and truncation only at a limit; `write` reports nothing at all. Every interpretation needs a spec for the reporting tool and a spec for the silent one. 22. **Entry shape changes without the buffer changing.** Re-anchoring an entry moves its bounds while touching no character, so anything cached per buffer must key on `entry.version` as well as `b:changedtick`. A fold cache keyed on the tick alone silently goes stale the moment an entry is rewritten. 23. **A hook only reaches buffers made after it.** `transcript.on_buffer`, `on_window` and `on_reset` listeners register at module load, so every module that binds keys or decorates must be required before the first transcript exists. `bugabinga.pivi` requires them all up front; a spec that requires only one of them silently gets a buffer with fewer mappings. 24. **A nameless buffer reads as the working directory.** `fnamemodify('', ':~:.')` returns the cwd, so an unnamed scratch buffer makes a statusline claim the user is editing a file in it, which is where `system32` came from under a launcher that starts in `C:\Windows\system32`. Name every buffer pivi creates, floats included. 25. **Never shadow a key whose meaning you are changing.** pivi publishes `` intents; a configuration binds them. `tests/bugabinga/pivi/keys_spec.lua` asserts pivi claims no bare key in any of its buffers, with `/` in a prompt as the single declared exception. Accepting a completion and accepting a prompt are the same intent, so `(pivi-accept)` yields to an open popup instead of replacing it. 26. **`std.auto` clears its group on every call.** Registering two autocmds with the same group name in two calls silently deletes the first; pass one list instead. 27. **A session may be a stand-in.** The drive harness passes a stub session, so code reached from a command must not assume `session.buffer` is a live buffer. This is exactly the class of regression the drive harness exists to catch; it caught it. 28. **Pi runs the commands it owns and says the rest.** A `/name` sent as a prompt is dispatched when Pi owns the command and becomes a user message when it does not, so a name must be checked against `get_commands` before it is sent. Terminal-only commands such as `/model` are absent from that list; offer the capability natively instead of forwarding the name. 29. **A picker filters the text it is shown.** Formatting an item as `name description` makes every description compete with the name; show the name alone. 30. **A written prompt is not always a prompt.** `bugabinga.pivi.compose` decides: a leading bang is a shell command through pi's `bash`, which accepts `excludeFromContext` even though `rpc.md` does not document it, and `@name` is expanded client-side because pi has no server-side `@` over this channel. Expansion reuses `edit.read`, so a named file is sent as its buffer content when it is open and unsaved; anything else would contradict every other pivi read. The wire shape is pi's own: ` content `, images as `images[]`. 31. **A local in Lua is not visible before its declaration.** Two functions have now been used above their definition and resolved to `nil` at runtime; if a helper is called from an earlier function, define it earlier. 32. **Roles are named, not coloured.** `bugabinga.pivi.highlight` defines `PiviUser`, `PiviUserBar`, `PiviUserLabel`, `PiviAssistantLabel`, `PiviTool`, `PiviToolIcon`, `PiviToolOutcome`, `PiviError` and `PiviErrorIcon` as defaults and re-applies them on `ColorScheme`; nugu defines them for real. The intent any theme must keep: only a user message carries a background, because it is the one thing a reader scrolls back to find. An answer takes no colour at all. Tool output recedes furthest, and its outcome stays scannable. Never reach for `Comment` to mean dim: in nugu it is bold, italic and important, which is the opposite. 33. **Contract tests guard assumptions,** not features. They skip unless `pi` is on `PATH` and opt-in is set. When Pi changes behavior, they fail before users do. ## Commands ``` just test just test tests/bugabinga/pivi just drive just test-contract ``` End-to-end proof that the companion extension answers from the live editor, when a tool changes: ``` nvim --headless --noplugin -u tests/minimal_init.lua --listen 127.0.0.1:6911 PIVI_NVIM_ADDRESS=127.0.0.1:6911 PIVI_NVIM_CLIENT=$PWD/lua/bugabinga/pivi/extension/client.lua pi --mode rpc --no-session --thinking low --model hetzner/Qwen3.8-27B --tools nvim_help --skill lua/bugabinga/pivi/skills/pivi/SKILL.md -e lua/bugabinga/pivi/extension/index.ts ``` `just drive` starts its own headless server, connects with `vim.fn.sockconnect('tcp', address, { rpc = true })`, and drives it with `vim.rpcrequest(channel, 'nvim_exec_lua' | 'nvim_input' | 'nvim_get_mode', ...)`. Requests are synchronous, so scenarios need no sleeps. Add scenarios to `tests/drive/run.lua`. ## Presentation pivi decorates only its own windows and publishes state for everyone else. | Surface | Rule | | --- | --- | | Transcript and prompt windows | window-local `winbar` carries the session name; the prompt also names its submit key | | Progress | `transcript.set_state` shows the running tool in pivi's own winbar | | Statusline | `lua/bugabinga/pivi_status.lua` is a consumer: it reads `vim.g.pivi` and never requires pivi, so it degrades when pivi is absent | | Headings | write `## pi` only when real text arrives, so a tool-only turn leaves no empty heading | | Leading blank lines | models open with them; strip them before the first visible character | | Rejections | name the command the user should type, not the internal state | | Transcript keys | buffer-local only, plus a `` mapping; Neovim already ships `]m` and `[m` globally, so assert on mapping descriptions rather than on the absence of a key | | Folds | one fold per entry through `foldexpr`; appended text does not recompute folds until a redraw, so `apply` runs `zX` first and then drives folds from recorded intent | | Tool outcome | end-of-line virtual text on the entry's opening line, and repeated in the fold line so a collapsed entry stays useful | | Reported change | written as the tool formatted it and highlighted with `DiffAdd`/`DiffDelete` per line; `bugabinga.pivi.diff` rebuilds the prior content from the change itself and states when the file has moved on | | Streaming output | rewrite only the entry tail, skip per-line decoration while it runs, and never run `fold.apply` mid-stream | | Output language | parsed as a standalone string and applied as highlight extmarks, never as a fence, so transcript text stays what the tool produced | | Keys | pivi binds no bare key and publishes `` intents only; `lua/bugabinga/pivi_keys.lua` chooses the keys. A key is reused only where the local behaviour is the same idea as the global one, so `` activates, `K` shows information, `]]` and `[[` move by section, and folding needs no key because entries are real folds | | Guide | `?` reads the mappings that exist in the buffer rather than a list written beside them, so it cannot drift | | Layout | `bugabinga.pivi.layout` decides beside or below from the editor size, follows the user's `splitright` and `splitbelow`, and never carves a new window out of a pivi window. Headless neovim is a fixed 80x24 grid, so the decision is unit-tested as a pure function and the windows are verified in a real terminal | ## Gotchas - Resolve the Pi executable to a real path before spawning. On Windows the launcher is a `.CMD` shim, a bare `pi` reaches the spawner as `ENOENT`, and a Neovim started from a desktop launcher has a smaller `PATH` than a shell. A failed spawn must be reported, never raised as a traceback in the editor. - End a Pi child by closing its input and only killing it after a grace period. On Windows the launcher is a shim, so an immediate kill orphans the runtime behind it and those orphans keep later test runs from exiting. Contract tests share one child per command line and shut it down explicitly. - Plenary discovers spec files by spawning `pwsh` with a 5 second timeout. Under load that fails with `E5108` and no tests run at all. Run the file directly when that happens; an empty summary is a harness failure, not a pass. - Never pipe a Plenary run into `tail`, `grep`, or `head`. Plenary spawns child Neovim processes that inherit stdout, so the pipe stays open and the shell appears to hang long after the tests finished. Redirect to a file and read the file. - A `vim.wait` predicate that already holds when the wait begins asserts nothing. `run == 'idle'` is true before the first frame is ever dispatched; wait for an observed event instead, or use the fake's `flush` barrier. - Real extension traffic dominates a recorded run: a recorded stream is mostly `setStatus` and `setWidget`, and the extensions clear their own entries on shutdown, so end-of-run status maps are empty by design. - Thinking levels are provider-specific. `--thinking off` is rejected by some endpoints; the run then fails with `stopReason: "error"` and an `errorMessage` on `message_end`, not with a transport failure. - `nvim --server ... --remote-expr` / `--remote-send` is unusable on win32 here: it spawns a TUI and swallows stdout. Use socket RPC. - A headless server has no UI, so `screenstring()` and friends are meaningless. Grid truth requires a real UI client or a WezTerm capture. - Overriding a built-in Pi tool requires matching its exact result and `details` shape; prefer injecting operations over reimplementing the tool, and assert the shape in a contract test. - A plain `prompt` while Pi is streaming is rejected; every submission path must choose steering or follow-up explicitly and a test must cover the rejection. - `:Pi help` must spawn its own session-less run; assert the working session's history, queue, and status are untouched. ## Module conventions Follow `neovim/AGENTS.md`: no `local M = {}`, snake_case files, `*_spec.lua` mirroring source paths. Test-visible entry points use the `_G.BugabingaPivi` global; library submodules do not.