897 lines
43 KiB
Markdown
897 lines
43 KiB
Markdown
# Generated-buffer immutability
|
||
|
||
**PROPOSED — needs explicit user approval before implementation. DO NOT
|
||
implement, DO NOT merge.**
|
||
|
||
**Revision 1 — scouted against canonical `githubsucks/main` @ `ad41cf1`,
|
||
2026-07-28. Every claim below about pmacs was executed, not read.** The
|
||
reproductions in §0 are transcripts of throwaway probes run in this
|
||
worktree at `ad41cf1` and deleted before the commit; the counts in §1
|
||
are whole greps with the arithmetic shown, never `| head`.
|
||
|
||
This closes the class-wide half of the invariant `Buffer::set_generated_contents`
|
||
opened in terminal copy mode (#178) and that `docs/agent-handoff.md` §4 and
|
||
`COHERENCE.md` §14 both record as unfinished: **four writer mechanisms across
|
||
five buffer families still pair an erroring intercept with `bypass_intercept`
|
||
writes over a writable rope, and every one of them is emptied by undo.**
|
||
|
||
Two things the arc turns out NOT to be, both discovered by measurement:
|
||
|
||
- It is **not** "compile is the urgent one". `compile.lua` and the
|
||
`*search-results*` panel rebind all seven undo chords to a no-op
|
||
(`compile.lua:219`, `builtin/commands/default.lua:855`), so reaching
|
||
them needs `M-x`. **`dired.lua` and `listview.lua` rebind nothing**, so
|
||
a bare `C-/` empties them. The cheap half is also the exposed half.
|
||
- It is **not** a Lua-only change. Two of the four mechanisms write
|
||
incrementally and cannot use the shipped primitive at all, and a
|
||
buffer the shipped primitive has locked **refuses `bypass_intercept`
|
||
writes** (§2.4, measured) — so partial adoption is impossible and a
|
||
new Rust primitive is required.
|
||
|
||
---
|
||
|
||
## 0. The bug, reproduced
|
||
|
||
`Buffer::undo` (`src/buffer.rs:1301`) gates on `ensure_writable()`
|
||
(`src/buffer.rs:568`) and nothing else. `ensure_writable` reads the Rust
|
||
`read_only` field; it never consults the intercept chain. The
|
||
`pmacs.buffer.add_intercept(buf, function() error(name .. " is read-only") end)`
|
||
idiom therefore protects the *edit* path and leaves the *history* path
|
||
wide open, while the owner's own `bypass_intercept` paint lands on the
|
||
undo stack for undo to pop.
|
||
|
||
The user-reachable chain, verified end to end:
|
||
|
||
`M-x buffer.undo` → `cmd { name = "buffer.undo" }`
|
||
(`builtin/commands/default.lua:179`) → `ed.undo()` → `EditorCore::undo`
|
||
(`src/editor_core.rs:2575`) → `Buffer::undo` → `ensure_writable`. The
|
||
chords `C-/ C-_ C-4 C-x u` are bound globally
|
||
(`builtin/keymaps/default.lua:126-136`) and the menu carries it too
|
||
(`builtin/menus/default.lua:141`). **No buffer-local rebinding removes
|
||
the command**, which is what `compile.lua`'s own comment already admits
|
||
("command/menu undo stays dispatchable", `compile.lua:236`).
|
||
|
||
### 0.1 Measured transcripts
|
||
|
||
Every line below is probe output from `ad41cf1`.
|
||
|
||
**listview panel, plain `C-/` through `dispatch_key`** — no rebinding
|
||
exists, so this is the whole distance from a keystroke to an empty panel:
|
||
|
||
```
|
||
listview BEFORE = "H\nrow-one\nrow-two"
|
||
listview after C-/ = ""
|
||
```
|
||
|
||
**listview panel, ordinary edit** — the intercept works, which is exactly
|
||
why the idiom reads as safe:
|
||
|
||
```
|
||
listview ORDINARY EDIT = false | intercept rejected the edit:
|
||
builtin/runtime/listview.lua:102: *probe-panel* is read-only
|
||
```
|
||
|
||
**dired listing, one `buffer.undo`:**
|
||
|
||
```
|
||
dired BEFORE = "/tmp/.tmpEJlp9i:\n -rw-r--r-- 1 2026-07-28 17:42 alpha.txt\n -rw-r--r-- 1 2026-07-28 17:42 beta.txt"
|
||
dired AFTER 1 = ""
|
||
```
|
||
|
||
**`*shell-command*`, `M-x buffer.undo` through the real minibuffer** (`M-x`,
|
||
typed `buffer.undo`, RET):
|
||
|
||
```
|
||
shell BEFORE = "$ printf ...\nDirectory: ...\n\none\ntwo\n\n[shell exited with code 0]\n"
|
||
shell after M-x buffer.undo
|
||
= "$ printf ...\nDirectory: ...\n\none\ntwo\n\n[output desynced by external edit]\n"
|
||
```
|
||
|
||
**Read that one carefully — it is the single most important measurement in
|
||
this document.** The Q#CM2 revision guard *noticed* and appended its
|
||
desync marker. It did **not** prevent anything: the run's exit status is
|
||
gone for good, and the buffer is still non-empty. Any acceptance
|
||
criterion phrased as "the buffer is not empty" **passes with the bug
|
||
live**. See §5, Stage 2 criterion 1.
|
||
|
||
Driven programmatically to the end, the same buffer empties completely:
|
||
|
||
```
|
||
shell AFTER 1 undo = "... one\ntwo\n" (exit marker gone)
|
||
shell AFTER 13 undos = ""
|
||
```
|
||
|
||
**`*search-results*`:**
|
||
|
||
```
|
||
search BEFORE = "Searching for: fn main\n\n"
|
||
search AFTER = ""
|
||
```
|
||
|
||
### 0.2 The fix already in the tree, and what it proves
|
||
|
||
`terminal.lua` is the adopter and the precedent. `render_snapshot`
|
||
(`terminal.lua:320-337`) calls `pmacs.buffer.set_generated_contents`, and
|
||
the comment at `:322-336` documents this exact defect in these exact
|
||
terms. `claim_snapshot` (`:339-396`) keeps the erroring intercept **and**
|
||
`set_round_trip_input`, and `:351-366` states the layering that this
|
||
framing must preserve at every adopter:
|
||
|
||
> rope-level read-only protects the daemon copy, round-trip input
|
||
> protects the replica copy — and neither substitutes for the other.
|
||
|
||
A locked buffer measured at `ad41cf1`:
|
||
|
||
```
|
||
bypass write after lock = false | buffer `*probe*` (id BufferId(4)) is read-only
|
||
M-x buffer.undo after lock leaves = "header\n"
|
||
```
|
||
|
||
---
|
||
|
||
## 1. The census, with its arithmetic
|
||
|
||
### 1.1 `bypass_intercept` — 21 grep hits, 16 write sites
|
||
|
||
`grep -rn bypass_intercept builtin` returns **21** lines. Five of them
|
||
are prose in comments, not calls:
|
||
|
||
| file:line | what it is |
|
||
|---|---|
|
||
| `compile.lua:265` | comment above `ensure_slot`'s intercept |
|
||
| `terminal.lua:304` | comment in `unique_snapshot_name` |
|
||
| `terminal.lua:324` | comment in `render_snapshot` (the round-2 note) |
|
||
| `dired.lua:478` | comment above `claim_handle` |
|
||
| `listview.lua:9` | module header |
|
||
|
||
21 − 5 = **16 actual write call sites**, and the per-file arithmetic is
|
||
9 + 4 + 1 + 2 + 0 = 16:
|
||
|
||
| file | writes | lines |
|
||
|---|---|---|
|
||
| `compile.lua` | 9 | 319, 443, 454, 465, 506, 512, 642, 794, 798 |
|
||
| `builtin/commands/default.lua` | 4 | 827, 849, 1005, 1007 |
|
||
| `dired.lua` | 1 | 371 |
|
||
| `listview.lua` | 2 | 60, 61 |
|
||
| `terminal.lua` | **0** | — (it adopted the primitive) |
|
||
|
||
**Two corrections to the counts this lane was briefed with.** `compile.lua`
|
||
has **9** write sites, not 10 — the tenth hit is the comment at `:265`.
|
||
`terminal.lua`'s two hits are **both comments**; it performs no
|
||
`bypass_intercept` write at all, which is the correct state for an
|
||
adopter and is worth stating because the raw grep count reads as though
|
||
it still does.
|
||
|
||
### 1.2 `add_intercept` — 17 Lua sites, 6 production
|
||
|
||
`grep -rn --include='*.lua' add_intercept . --exclude-dir=target`
|
||
returns **17** lines: **6** in `builtin/`, **11** under `tests/fixtures/`.
|
||
One of the eleven (`tests/fixtures/pmacs-mcp-prompts/init.lua:84`) is a
|
||
doc comment, so the fixture *call* count is 10; 6 + 10 = 16 calls across
|
||
17 lines. The six production sites:
|
||
|
||
| site | buffer(s) | shape |
|
||
|---|---|---|
|
||
| `terminal.lua:367` | terminal copy snapshot | blanket read-only — **adopted** |
|
||
| `dired.lua:509` | every dired buffer | blanket read-only |
|
||
| `listview.lua:101` | every listview panel | blanket read-only |
|
||
| `compile.lua:266` | `*compilation*`, `*shell-command*` | blanket read-only |
|
||
| `builtin/commands/default.lua:869` | `*search-results*` | blanket read-only |
|
||
| `builtin/packages/repl/init.lua:187` | REPL buffers | **filtering** — §2.5 |
|
||
|
||
### 1.3 `set_read_only` — zero Lua callers, and no Lua binding
|
||
|
||
`grep -rn set_read_only builtin tests` returns 5 hits, **all Rust test
|
||
code** (`tests/folding_acceptance.rs:587`,
|
||
`tests/vterm_stage1_acceptance.rs:139,175`,
|
||
`tests/terminal_copy_mode_acceptance.rs:582,584`). The stronger fact:
|
||
the Lua binding table registers `"add_intercept"` and no
|
||
`"set_read_only"` / `"is_read_only"` at all
|
||
(`src/lua_bindings/mod.rs:3409` is the only match in the neighbourhood).
|
||
Lua *cannot* set `read_only` today. That matters for Q#GB7.
|
||
|
||
### 1.4 The classification, by writer mechanism
|
||
|
||
**Class A — erroring intercept + `bypass_intercept` writes over a
|
||
writable rope. This is the bug.**
|
||
|
||
1. **`terminal.lua`** — copy-mode snapshot. **ADOPTED** (`:336`). Fixed.
|
||
2. **`dired.lua`** — every dired buffer. One write, in `paint`
|
||
(`:369-372`): `handle.buf:replace(0, handle.buf:len(), text, {bypass_intercept=true})`.
|
||
A whole-buffer replace already. **Convertible with the shipped
|
||
primitive.**
|
||
3. **`listview.lua`** — `*references*`, `*outline*`, `*lsp-help*` (the
|
||
three production `listview.open` callers, all in `lsp.lua`:2056, 2102,
|
||
2513). One writer, `render` (`:50-62`): delete-all then insert-all,
|
||
which is a whole-buffer replace spelled in two ops.
|
||
**Convertible.**
|
||
4. **`compile.lua`** — `*compilation*` and `*shell-command*`, both via
|
||
`ensure_slot` (`:258`). Nine writes across five enclosing functions,
|
||
and they are genuinely incremental:
|
||
|
||
| enclosing function | line | shape |
|
||
|---|---|---|
|
||
| `resync` (`:309`) | 319 | append desync marker at end |
|
||
| `emit_text` (`:432`) | 443 | append remainder at end |
|
||
| `emit_text` | 454 | append `"\n"` at end |
|
||
| `emit_text` | 465 | **positional `replace`** (CR overwrite) |
|
||
| `apply_events` (`:480`) | 506 | **targeted delete** (erase-to-eol) |
|
||
| `apply_events` | 512 | **targeted delete** (erase-line) |
|
||
| `emit_text_raw` (`:639`) | 642 | append marker at end |
|
||
| `start_run` (`:746`) | 794 | delete-all (run reset) |
|
||
| `start_run` | 798 | insert header (run reset) |
|
||
|
||
**NOT convertible.** `emit_text` is a terminal emulator: it tracks
|
||
`slot.out_pos` / `slot.line_start` / `slot.parse_line_start` as byte
|
||
anchors, reads `buf:slice(pos, len)` between writes, and settles
|
||
`slot.expected_rev = buf:revision()` afterwards. A whole-buffer
|
||
replace destroys every one of those anchors.
|
||
5. **`builtin/commands/default.lua`** — the independent `*search-results*`
|
||
panel (`ensure_search_panel`, `:857`). Four writes across three
|
||
enclosing functions:
|
||
|
||
| enclosing function | line | shape |
|
||
|---|---|---|
|
||
| `search_panel_resync` (`:821`) | 827 | append desync marker |
|
||
| `search_panel_append` (`:844`) | 849 | append match batch |
|
||
| `pmacs.project.search` (`:982`) | 1005 | delete-all (query reset) |
|
||
| `pmacs.project.search` | 1007 | insert header (query reset) |
|
||
|
||
**NOT convertible**, for the same reason at smaller scale: the append
|
||
path carries `p.next_row` / `p.expected_rev` bookkeeping.
|
||
|
||
**Do not read `ensure_slot` as covering this panel.** It serves
|
||
`*compilation*` and `*shell-command*` only; `*search-results*` has its
|
||
own intercept, its own round-trip mark, its own resync and its own
|
||
writes, and `compile.lua` names it only inside the
|
||
`is_generated_buffer` predicate (`:216`).
|
||
|
||
**Class B — filtering intercept, deliberately partly editable.**
|
||
|
||
6. **`builtin/packages/repl/init.lua:187`** — see §2.5. Shares the root
|
||
cause, does not share the remedy. **Out of this arc.**
|
||
|
||
**Class C — generated, but nothing ever claimed they were protected.**
|
||
Keying the inventory on `bypass_intercept` misses these entirely,
|
||
because an unprotected buffer needs no bypass:
|
||
|
||
7. **`*buffer-list*`** — `render_list` (`default.lua:387`) writes with
|
||
plain `buf:delete(0, len)` / `buf:insert(0, body)` (`:403-404`). No
|
||
intercept, no round-trip mark. Whole-replace shape.
|
||
8. **`*help*`** — `show_help_text` (`default.lua:1239`), same plain
|
||
delete-all + insert-all (`:1245-1246`). No intercept.
|
||
9. **`*workers*`** — a **Rust** writer, `workers_buffer::render`
|
||
(`src/workers_buffer.rs:65`), using `Buffer::apply_edit` (not the
|
||
skip-intercepts path), delete-all + insert-all, then
|
||
`Buffer::mark_clean()` (`:95`). Its fan-out is a fourth mechanism:
|
||
`queue_generated_buffer_edits` + `rebuild_generated_buffer_views`
|
||
(`src/lua_bindings/mod.rs:7142-7145`).
|
||
|
||
Class C is a **different defect** — nothing is defeated, because nothing
|
||
was claimed. It is named here so the inventory is complete and so a
|
||
future reviewer does not re-derive it; §4 keeps it out of this arc.
|
||
|
||
### 1.5 A correction to `COHERENCE.md` §14 (not edited here)
|
||
|
||
§14 states that "references, outline, buffer-list, and project-search all
|
||
use" listview. Measured: `pmacs.listview.open` has **three** production
|
||
callers, all in `lsp.lua` — `*references*` (`:2056`), `*outline*`
|
||
(`:2102`), `*lsp-help*` (`:2513`). `*buffer-list*` is hand-rolled in
|
||
`default.lua` (`render_list`, `:387`) and `*search-results*` is the
|
||
independent grep panel. Two of §14's four examples are wrong, and
|
||
`*lsp-help*` is missing. `COHERENCE.md` is not this lane's file to edit;
|
||
recorded here and in the PR body.
|
||
|
||
---
|
||
|
||
## 2. Ground truth (measured, not recalled)
|
||
|
||
### 2.1 What the shipped primitive is
|
||
|
||
`Buffer::set_generated_contents` (`src/buffer.rs:545`, doc comment
|
||
`:507-544`): lift `read_only`, `apply_edit_skip_intercepts` a **single
|
||
whole-buffer** `EditOp::Replace`, `clear_history()` (`:559`), re-assert
|
||
`read_only`, **return the `Edit`**. The Lua binding
|
||
(`src/lua_bindings/mod.rs:3079-3095`) fans that `Edit` out via
|
||
`notify_buffer_edit_to_windows` (`:1573`) *after* dropping the registry
|
||
borrow, because the fan-out re-enters the core.
|
||
|
||
`clear_history` clears whichever history the buffer has: the v0.1
|
||
`undo`/`redo` stacks, and in CRDT mode `CrdtState::clear_undo_history`
|
||
(`src/crdt.rs:507`), which rebinds a fresh `UndoManager` to the same doc
|
||
because loro exposes no `clear`.
|
||
|
||
### 2.2 Why history clearing is load-bearing
|
||
|
||
The doc comment's reason is retention, not tidiness: `read_only`
|
||
guarantees the pushed entries can never be popped, so a periodically
|
||
refreshed panel accumulates full rope clones nothing will ever release.
|
||
CRDT mode has the identical retention inside loro's `UndoManager`.
|
||
|
||
### 2.3 Why a bare lock is not the answer
|
||
|
||
`ensure_writable` guards the bypass path too
|
||
(`apply_edit_skip_intercepts`, `src/buffer.rs:1055-1056`). Locking a
|
||
generated buffer without giving its owner a door refuses the refresh the
|
||
buffer exists for. That is why the *pairing* is the primitive and why
|
||
there is deliberately no Lua `set_read_only` today.
|
||
|
||
### 2.4 Partial adoption is impossible — measured
|
||
|
||
This is the fact that decides the design. Once `set_generated_contents`
|
||
has locked a buffer, a subsequent owner write through `bypass_intercept`
|
||
is refused:
|
||
|
||
```
|
||
bypass write after lock = false | buffer `*probe*` (id BufferId(4)) is read-only
|
||
```
|
||
|
||
So compile **cannot** convert its run reset (`start_run:794,798`) to the
|
||
shipped primitive and keep `bypass_intercept` for streaming: the first
|
||
append after the reset raises. The streaming owner needs a write path
|
||
that *itself* carries authority. Reads are unaffected — `buf:slice`,
|
||
`buf:len` and `buf:revision` all work on a locked buffer, which is what
|
||
makes an op-level solution viable at all.
|
||
|
||
### 2.5 The REPL: same root cause, different remedy — measured
|
||
|
||
`builtin/packages/repl/init.lua:187` installs
|
||
`function(op) return repl._intercept(h, op) end` — a **filtering** policy
|
||
(`repl._intercept`, `:686-726`): reject edits wholly inside the
|
||
history/prompt region, **truncate** edits that straddle the boundary,
|
||
pass edits in the input region. Its own writes use a `_self_write` flag
|
||
(`with_self_write`, `:111-116`) rather than `bypass_intercept`, and it
|
||
has real teardown (`remove_intercept`, `:325-327`).
|
||
|
||
Does it share the bug? **Yes — measured — and I am not asserting the
|
||
comfortable answer:**
|
||
|
||
```
|
||
repl BEFORE = "line one\nline two\n> "
|
||
repl ordinary edit at pos 0 = false | REPL: history/prompt region is read-only
|
||
(insert at 0; input region begins at 20)
|
||
repl AFTER 1 buffer.undo = "line one\nline two\n"
|
||
repl bookkeeping after undo = _history_end=18 / _prompt_end=20 (rope is 18 bytes)
|
||
```
|
||
|
||
Undo deleted the prompt the intercept had just refused to let anyone
|
||
touch, and left `_prompt_end` pointing two bytes past the end of the
|
||
rope. The marks (`_history_end_mark`, `_prompt_end_mark`) adjust with the
|
||
rope, but `_blocks[i].start_byte` are plain integers maintained by hand
|
||
in `drop_oldest_block` (`:643-656`) and do not.
|
||
|
||
**But the remedy cannot be rope-level `read_only`**: the input region
|
||
must accept ordinary user edits, which is the whole point of a REPL. The
|
||
REPL needs either an undo that consults the intercept chain, or
|
||
mark-anchored blocks. Both are different work. **Q#GB8: out of this arc,
|
||
named deferral, with its measurement recorded above so the next lane does
|
||
not have to rediscover it.**
|
||
|
||
### 2.6 A pre-existing defect in the shipped primitive — measured
|
||
|
||
`notify_buffer_edit` (`src/editor_core.rs:1814`) updates each window's
|
||
`TextView` and overlays. It does **not** clamp `win.cursor` or
|
||
`win.view_top`. Only `rebuild_views_for` (`:1843`) does, and its doc
|
||
comment says so explicitly (`:1841-1842`). `set_generated_contents`'s
|
||
binding calls the former.
|
||
|
||
```
|
||
cursor before = 29, len = 30
|
||
cursor after set_generated_contents(G, 'x\n') = 29, len = 2
|
||
row0 after shrink = "x" (paint did not crash)
|
||
cursor after C-p = 29 (motion did not recover it)
|
||
```
|
||
|
||
A shrinking generated write leaves the window cursor 27 bytes past the
|
||
end of the buffer, indefinitely. **This ships today in terminal copy
|
||
mode** — refresh a snapshot to a shorter one with the point low in the
|
||
buffer and this is the state — and every adopter inherits it. Q#GB6.
|
||
|
||
### 2.7 What `buffer.after-edit` does and does not do
|
||
|
||
`buf:insert` / `buf:delete` / `buf:replace` do **not** fire
|
||
`buffer.after-edit`; the dispatcher and daemon do
|
||
(`src/editor.rs:1436,1984,2128,2163`, `src/daemon.rs:2976`).
|
||
`compile.lua:714` already relies on this ("hook edits don't re-fire the
|
||
hook"). Consequence for §3: a generated write does not run arbitrary Lua,
|
||
so the *fan-out* is not a re-entrancy hazard — but a scoped primitive's
|
||
**callback body** still is, because it is arbitrary owner Lua.
|
||
|
||
---
|
||
|
||
## 3. The primitive decision (Q#GB1)
|
||
|
||
**The question this arc exists to answer: what write primitive do
|
||
`compile.lua` and the search panel need?**
|
||
|
||
### 3.1 Recommendation
|
||
|
||
**`Buffer::apply_generated_edit(op: EditOp) -> Result<Edit, BufferError>`
|
||
— one authorized op at a time — exposed to Lua as a new option key on
|
||
the mutators that already exist:**
|
||
|
||
```lua
|
||
buf:insert(pos, text, { generated = true })
|
||
buf:delete(start, end_, { generated = true })
|
||
buf:replace(s, e, text, { generated = true })
|
||
```
|
||
|
||
Semantics, per call, entirely inside one `with_registry_mut`: lift
|
||
`read_only` → `apply_edit_skip_intercepts(op)` → `clear_history()` →
|
||
re-assert `read_only` → return the `Edit`. The binding then fans it out
|
||
through the `notify_buffer_edit_to_windows` call it **already makes**
|
||
(`src/lua_bindings/mod.rs:1291`, `:1302`, `:1322`), after the borrow has
|
||
dropped.
|
||
|
||
`Buffer::set_generated_contents(bytes)` is reimplemented as
|
||
`apply_generated_edit(Replace { range: 0..len, bytes })`. **It keeps its
|
||
name, its signature, its doc comment and its tests** — it becomes the
|
||
whole-buffer spelling of one primitive rather than a second primitive.
|
||
|
||
**One sentence for why it wins: it is the only candidate in which the
|
||
buffer is never observably unlocked, because the lift and the re-assert
|
||
happen inside a single registry borrow with no Lua in between — so there
|
||
is no flag to clear on an error path, no yield to defend against, and
|
||
nothing for a reviewer to audit site by site.**
|
||
|
||
### 3.2 Why the alternatives lose
|
||
|
||
**A. `append_generated_contents` — provably insufficient.** `compile.lua`
|
||
does a positional `replace` at `emit_text:465` (the CR overwrite that
|
||
makes progress bars work) and two targeted `delete`s at
|
||
`apply_events:506,512` (erase-to-eol, erase-line). Append cannot express
|
||
any of the three. Dead on the census.
|
||
|
||
**B. Scoped `with_generated_writes(buf, fn)` — the pattern this project
|
||
has already been burned by.** It is the cheapest on history (one
|
||
`clear_history` per scope instead of one per op) and that is its only
|
||
real advantage. Against it:
|
||
|
||
- **The unlocked interval is the callback's whole duration, and the
|
||
callback is arbitrary owner Lua.** Drawn loosely around `start_run`
|
||
(`compile.lua:746`), the scope spans `pmacs.window.switch_buffer(buf)`
|
||
and `pcall(pmacs.process.spawn, spec)` — the buffer would be writable
|
||
across a process spawn. Drawn tightly, compile needs four separate
|
||
scopes (`start_run`'s reset, `resync`, `feed_bytes`, `finish_run`), each
|
||
needing its own audit for what the body reaches.
|
||
- **Correctness reduces to "a flag cleared on every exit."** That is the
|
||
exact shape `docs/agent-handoff.md` §5 and #155 record as a repeat
|
||
offender, and the REPL already had to defend its own version of it:
|
||
`with_self_write` (`repl/init.lua:111-116`) wraps in `pcall`
|
||
specifically because "a single failed write would leave the bypass on
|
||
for every subsequent user edit". Adding a second instance of a pattern
|
||
the tree already documents as fragile is a poor trade for one saved
|
||
`clear_history` per batch.
|
||
- **Yield is an error here, which helps but does not rescue it.** A Lua
|
||
callback that yields across the Rust boundary raises
|
||
`attempt to yield across C-call boundary` (observed in this worktree
|
||
while probing `pmacs.dired.open`), so a yielding body surfaces as
|
||
`Err` — but the relock must still run on that path, which is the same
|
||
obligation.
|
||
- It is strictly harder to review: a per-site scope audit versus a
|
||
mechanical option-key change at 16 call sites.
|
||
|
||
**C. Standalone `generated_edit(buf, op)`.** Identical semantics to the
|
||
recommendation, worse ergonomics: it re-implements the three-op argument
|
||
parsing that `buf:insert/delete/replace` already own, and turns adoption
|
||
from an option-key change into a rewrite of 16 call sites. Recommended
|
||
only if the user objects to `{ generated = true }` sitting beside
|
||
`{ bypass_intercept = true }` in the same options table.
|
||
|
||
**D. Make `Buffer::undo`/`redo` consult the intercept chain.** This would
|
||
fix all six families at once, including the REPL, and it deserves an
|
||
explicit rejection rather than silence. Against it: (i) there is no
|
||
`EditOp` to hand the chain — v0.1 undo is a whole-rope swap
|
||
(`src/buffer.rs:1327-1328`) and CRDT undo is materialize-and-replace
|
||
(`undo_crdt_mode`, `:1365`), so the chain would have to be given a
|
||
synthetic op it was never designed to see; (ii) it changes behaviour for
|
||
every intercept in the tree, including the *transforming* ones
|
||
(auto-pair, lean-input, the REPL's truncation) which have no business
|
||
rewriting an undo; (iii) an erroring intercept becomes a new Lua-raise
|
||
failure path out of `EditorCore::undo`, which today cannot fail that way.
|
||
It may still be the right answer **for the REPL specifically** — recorded
|
||
in Q#GB8's deferral, not adopted here.
|
||
|
||
### 3.3 The four questions the recommendation must answer
|
||
|
||
**How many `Edit`s are fanned out, and when?** One per generated op,
|
||
immediately, by the binding that already does it. `compile.lua`'s
|
||
`emit_text` fast path emits one insert for a whole output batch, so a
|
||
typical `feed_bytes` produces one to three ops; a CR-heavy progress bar
|
||
produces more. This is exactly today's fan-out count — the conversion
|
||
changes authority, not cardinality.
|
||
|
||
**Per-op or per-scope history clearing?** Per op, and it is cheap by
|
||
construction: because `read_only` is re-asserted immediately, **at most
|
||
one** v0.1 undo entry can exist when the clear runs and the redo stack is
|
||
always empty, so the v0.1 clear is O(1). In CRDT mode the clear rebinds
|
||
a fresh `UndoManager` (`CrdtState::clear_undo_history`), and
|
||
`create_undo_manager` (`src/crdt.rs:154`) is `UndoManager::new(doc)` plus
|
||
`set_max_undo_steps` — a subscription registration, not a document copy,
|
||
so it is O(1) in document size too. **Measurement obligation, not a
|
||
claim:** Stage 2 must show a streaming compile run does not regress
|
||
against the existing compile-mode timings in both configurations. If it
|
||
does, the escape hatch is to suppress recording rather than clear it —
|
||
recorded as a named deferral rather than designed speculatively.
|
||
|
||
**CRDT-mode behaviour?** Identical to `set_generated_contents` today. The
|
||
`Edit` carries `crdt_op` when the buffer is CRDT-backed, and
|
||
`notify_buffer_edit_to_windows` queues it via
|
||
`queue_daemon_origin_crdt_op` (`src/lua_bindings/mod.rs:1582`) so replica
|
||
mirrors import the owner's write. History clearing goes to loro's
|
||
`UndoManager`. Nothing new.
|
||
|
||
**How do the returned edits reach the fan-out without a live registry
|
||
borrow?** By construction, unchanged since #178: `run_bypass_edit`
|
||
(`src/lua_bindings/mod.rs:1445`) closes its `with_registry_mut` before
|
||
returning, and the mutator bindings call
|
||
`notify_buffer_edit_to_windows` afterwards. `apply_generated_edit` slots
|
||
into the same place `apply_edit_skip_intercepts` occupies now.
|
||
|
||
---
|
||
|
||
## 4. Decisions
|
||
|
||
**Q#GB1 — The streaming primitive.** `Buffer::apply_generated_edit(op)`,
|
||
exposed as `{ generated = true }` on the three Lua mutators.
|
||
`set_generated_contents` becomes its whole-buffer wrapper, keeping name,
|
||
signature and tests. Rationale and rejected alternatives: §3.
|
||
|
||
**Q#GB2 — `generated` is additive; `bypass_intercept` stays.** Seven
|
||
call sites outside `builtin/` depend on `bypass_intercept`, including
|
||
`tests/folding_stage2_acceptance.rs:1296-1315`, which pins that a bypass
|
||
edit still triggers the Q#FD19 interactive unfold. Redefining the
|
||
existing key would silently change that pinned seam. `generated = true`
|
||
implies bypass; passing both is legal and `generated` wins (it is
|
||
strictly stronger); passing `generated` on a buffer with no intercept is
|
||
legal (Class C would use it if it ever adopts).
|
||
|
||
**Q#GB3 — Routing is otherwise identical to bypass.** A generated write
|
||
goes through `run_buffer_edit`'s bypass arm (`src/lua_bindings/mod.rs:1368`),
|
||
including `unfold_before_interactive_lua_edit`. The unfold guard already
|
||
requires `InteractiveCommandOrigin::current()` to be `Some`, which is
|
||
false for the `process.after-tick` pump and true for `M-x compile`;
|
||
folding a `*compilation*` buffer is possible, so changing this would be a
|
||
silent behaviour change to a pinned seam for no reason this lane owns.
|
||
|
||
**Q#GB4 — History cleared per op, with a measurement obligation.** §3.3.
|
||
Deferred optimization: suppress recording instead of clearing.
|
||
|
||
**Q#GB5 — The lock-at-creation gap, and who closes it.** A
|
||
`{ generated = true }` write locks the buffer *after* its first call, so
|
||
between `pmacs.buffer.create(name)` and the owner's first generated write
|
||
the rope is writable. `dired.lua` (`claim_handle` → `paint`),
|
||
`listview.lua` (`ensure_panel` → `render`) and the search panel
|
||
(`ensure_search_panel` → the header write in `pmacs.project.search`) all
|
||
write synchronously in the same call, so the window is not observable.
|
||
**`compile.lua`'s `ensure_slot` (`:258-282`) does not** — it creates
|
||
`*compilation*` and returns, leaving it empty and writable until
|
||
`start_run`. Recommendation: `ensure_slot` ends with
|
||
`pmacs.buffer.set_generated_contents(slot.buf, "")`, using the shipped
|
||
primitive; no third surface is needed. Note the pre-existing hazard this
|
||
inherits and does not create: `ensure_slot` is
|
||
`buffer_named(name) or create`, so it can adopt a foreign buffer, which
|
||
`start_run:794`'s delete-all already clobbers today.
|
||
|
||
**Q#GB6 — Clamp the window cursor on a generated write.** §2.6 measures a
|
||
shipped defect: a shrinking generated write leaves `win.cursor` past the
|
||
end of the rope, and neither paint nor `C-p` recovers it. Recommendation:
|
||
clamp `win.cursor` and `win.view_top` in `EditorCore::notify_buffer_edit`
|
||
when the buffer shrank — a **clamp**, not a call to `rebuild_views_for`,
|
||
because a rebuild is O(buffer length) and would run per streaming op.
|
||
Recommended for **Stage 1**, because Stage 1's adopters refresh shrinking
|
||
panels constantly and because it fixes terminal copy mode retroactively.
|
||
Alternative if the user prefers a narrower Stage 1: its own lane, in
|
||
which case Stage 1 must say so out loud rather than inherit it silently.
|
||
|
||
**Q#GB7 — wdired needs an unlock, and Lua cannot express one.** Dired
|
||
Stage 3 (`docs/dired-framing.md` §5) makes a dired buffer editable by
|
||
removing the read-only intercept and swapping the major mode. Once
|
||
dired's rope is `read_only`, removing the intercept is no longer
|
||
sufficient — and §1.3 measured that **no Lua binding can clear
|
||
`read_only`**. Recommendation: **name it, do not build it.** A binding
|
||
whose only consumer does not exist yet cannot be pinned against a real
|
||
caller, and "asserting that a value was stored is not asserting that
|
||
anything reads it" is a lesson this repo has already paid for. Stage 1
|
||
records it as a hard prerequisite on dired Stage 3's framing.
|
||
|
||
If the user rules the other way, the two shapes are: expose
|
||
`pmacs.buffer.set_read_only(buf, on)` — which **contradicts
|
||
`docs/agent-handoff.md` §4's "there is deliberately no Lua
|
||
`set_read_only`"**, and needs an explicit ruling rather than a quiet
|
||
addition, though the objection behind that invariant ("it also refuses
|
||
the owner's refresh") is answered once `{ generated = true }` ships — or
|
||
a one-way `pmacs.buffer.unlock_generated(buf)`, strictly weaker because
|
||
it can never lock anything.
|
||
|
||
**Q#GB8 — The REPL is out of this arc.** §2.5. Same root cause, different
|
||
remedy, its own lane. Its measured exposure is recorded above so the next
|
||
scout starts from evidence.
|
||
|
||
**Q#GB9 — Class C is out of this arc.** `*buffer-list*`, `*help*` and
|
||
`*workers*` are generated but were never claimed to be protected, so
|
||
nothing about them is *defeated*. Making them immutable is a product
|
||
decision about `COHERENCE.md` §14's list and output-channel primitives,
|
||
not a bug fix, and it should not ride a bug-fix arc. `*workers*`
|
||
additionally writes from Rust with its own fan-out pair and already
|
||
`mark_clean`s, so it is not a like-for-like conversion.
|
||
|
||
**Q#GB10 — Mark generated buffers clean.** `set_generated_contents`
|
||
leaves `is_modified = true` (measured: `modified=true` after one write),
|
||
so every adopter shows `*` in the mode line
|
||
(`src/editor.rs:3704`) and in `*buffer-list*` (`default.lua:395`).
|
||
`workers_buffer::render` calls `Buffer::mark_clean()` (`:95`) for exactly
|
||
this reason. Recommendation: `apply_generated_edit` marks clean.
|
||
**This changes shipped `set_generated_contents` behaviour** and therefore
|
||
the terminal snapshot, so it belongs in Stage 2 alongside the
|
||
reimplementation, not smuggled into Stage 1. Verified non-blocking: the
|
||
flag drives only the mode-line indicator and the buffer-list column —
|
||
`grep` finds no quit-time or kill-time prompt reading it.
|
||
|
||
**Q#GB11 — Staging.** §5.
|
||
|
||
**Q#GB12 — The revision guard becomes near-dead, and three tests break.**
|
||
After Stage 2, an external edit to `*compilation*` or `*search-results*`
|
||
is refused at the rope, so the Q#CM2 desync machinery (`check_rev`
|
||
`compile.lua:332`, `resync` `:309`, `search_panel_check_rev`
|
||
`default.lua:832`) can essentially no longer fire. Recommendation: **keep
|
||
it** — it is cheap, and a future Rust-side writer could still mutate the
|
||
buffer — but say so, and do not delete its tests. Three compile
|
||
acceptance tests inject intruder edits through `bypass_intercept`
|
||
(`tests/compile_mode_acceptance.rs:1040`, `:1106`, `:1305`) and **will be
|
||
refused** after conversion; they must lift `read_only` Rust-side first,
|
||
exactly as `tests/terminal_copy_mode_acceptance.rs:582-584` already does.
|
||
That is a concrete, verified integration cost of Stage 2, not a surprise
|
||
to discover during implementation.
|
||
|
||
---
|
||
|
||
## 5. Staging
|
||
|
||
**The proposed cut is endorsed, with two amendments.** The argument for
|
||
it is not the obvious one.
|
||
|
||
### Stage 1 — `generated-buffer-immutability-stage1`
|
||
|
||
`dired.lua` and `listview.lua` adopt `pmacs.buffer.set_generated_contents`.
|
||
No new primitive.
|
||
|
||
- `listview.lua:50-62` — `render`'s delete-all + insert-all becomes one
|
||
`set_generated_contents(buf, body)`.
|
||
- `dired.lua:369-372` — `paint`'s whole-buffer replace becomes one
|
||
`set_generated_contents(handle.buf, text)`.
|
||
- Both keep their erroring intercept (named error, per the layering at
|
||
`terminal.lua:351-366`) and both keep `set_round_trip_input`.
|
||
- Plus Q#GB6's cursor clamp, if approved.
|
||
|
||
**Why this cut, and why Stage 1 is not merely "the cheap half":** it is
|
||
the *worse-exposure* half. `compile.lua:219` and
|
||
`builtin/commands/default.lua:855` rebind all seven undo chords to
|
||
`compile.undo-noop`; `dired.lua` and `listview.lua` rebind **nothing** —
|
||
`grep -n 'C-/\|C-_\|C-x u\|undo' builtin/runtime/dired.lua builtin/runtime/listview.lua`
|
||
returns zero binding lines. Measured, a bare `C-/` empties a listview
|
||
panel and a dired listing. Stage 1 closes the only two families
|
||
reachable without `M-x`.
|
||
|
||
**Why the cut is safe under every candidate primitive:** a whole-buffer
|
||
replace is expressible in all of A–C, and under the recommendation
|
||
`set_generated_contents` keeps its name and signature as
|
||
`apply_generated_edit`'s wrapper. Stage 1 is therefore not rework under
|
||
any Q#GB1 outcome — which is the decisive argument for cutting here
|
||
rather than shipping one large PR.
|
||
|
||
**The honest objection, and the answer.** A reviewer could call Stage 1
|
||
churn: two call sites converted to a primitive Stage 2 then rewrites.
|
||
Stage 2 rewrites the primitive's *implementation*, not its callers; the
|
||
diff at `dired.lua:371` and `listview.lua:60-61` is written once.
|
||
|
||
### Stage 2 — `generated-buffer-immutability-stage2`
|
||
|
||
`Buffer::apply_generated_edit` + the `{ generated = true }` option +
|
||
`set_generated_contents` reimplemented over it + Q#GB10's `mark_clean` +
|
||
conversion of all 13 remaining write sites (`compile.lua` 9,
|
||
`builtin/commands/default.lua` 4) + Q#GB5's `ensure_slot` lock + the
|
||
three `compile_mode_acceptance` intruder tests updated per Q#GB12.
|
||
|
||
All the new Rust and all the review risk in one PR, which is the point of
|
||
the cut.
|
||
|
||
**Amendments to the briefed cut:**
|
||
|
||
1. **Q#GB7 (wdired) is recorded in Stage 1, not built in it.** Stage 1
|
||
makes dired's rope read-only, which creates an obligation for dired
|
||
Stage 3 that does not exist today. Recording it is the deliverable;
|
||
building an unlock binding with no caller is not.
|
||
2. **Q#GB10 (`mark_clean`) lands in Stage 2, not Stage 1**, because it
|
||
edits `set_generated_contents` itself and therefore changes the
|
||
already-shipped terminal snapshot. Stage 1 stays a pure-Lua change
|
||
(plus Q#GB6, if approved).
|
||
|
||
**Where the REPL lands: neither stage.** Q#GB8.
|
||
|
||
---
|
||
|
||
## 6. Acceptance, with the pre-image each criterion must fail against
|
||
|
||
`M-x buffer.undo` is the user-reachable trigger and **needs no keymap**.
|
||
A criterion that only exercises the intercept, or only the chords, proves
|
||
nothing — that is precisely what `compile.lua`'s idiom already achieves
|
||
and what this bug already defeats.
|
||
|
||
### Stage 1
|
||
|
||
1. **`C-/` cannot empty a listview panel.** Driven by `dispatch_key`,
|
||
not by a Lua call. *Bite:* on `ad41cf1` this measured
|
||
`"H\nrow-one\nrow-two"` → `""`.
|
||
2. **`M-x buffer.undo` cannot empty a listview panel**, driven through
|
||
the real minibuffer (`M-x`, type `buffer.undo`, RET) — not
|
||
`pmacs.command.invoke`, which is the programmatic path. *Bite:* same
|
||
empty result on the pre-image; and a chord-only fix passes 1 and
|
||
fails this.
|
||
3. **`C-/` and `M-x buffer.undo` cannot empty a dired listing.** *Bite:*
|
||
measured — one undo takes the listing to `""`.
|
||
4. **The owner's own refresh still works after the lock** — `g` on a
|
||
listview panel and on a dired buffer re-renders new content. *Bite:*
|
||
this is the criterion that falsifies the **obvious wrong fix**, not
|
||
the pre-image: a naive `set_read_only(true)` at creation passes 1–3
|
||
and fails here, which is the failure mode `src/buffer.rs:521-524`
|
||
exists to prevent. Assert the *new* content appears, not that the
|
||
call did not raise.
|
||
5. **An ordinary edit is still refused with the intercept's named
|
||
message.** *Bite:* deleting the intercept while keeping the rope lock
|
||
passes 1–4 and fails this; the layering at `terminal.lua:351-366`
|
||
requires the named error to survive.
|
||
6. **`set_round_trip_input` is still set on both.** Pinned ungated, via
|
||
`dispatch_idle_for` reporting **false** while the panel is focused —
|
||
the shape `tests/terminal_copy_mode_acceptance.rs` criterion 16 uses,
|
||
which needs no CRDT. *Bite:* delete the `set_round_trip_input` call
|
||
and 1–5 all still pass; only this fails. A daemon-side refusal does
|
||
nothing for a replica's own mirror.
|
||
7. **A refresh reaches the window, not just the rope** — pinned by
|
||
**painting** a shrinking render (many rows → one) and asserting row 1
|
||
is empty, for each adopter. *Bite:* not redundant with #178's
|
||
criterion 16d, because it catches a **partial** conversion — an
|
||
adopter that keeps one `bypass_intercept` write beside the primitive
|
||
— which 16d cannot see.
|
||
8. **Cursor clamp (only if Q#GB6 is approved).** After a shrinking
|
||
refresh, `pmacs.editor.cursor() <= buf:len()`, and `C-p` moves. *Bite:*
|
||
measured on `ad41cf1` — cursor 29, len 2, `C-p` leaves it at 29. This
|
||
pin **fails on `main` today**, including for terminal copy mode, which
|
||
is the evidence it is a real fix and not bookkeeping.
|
||
9. **Structural, riding alongside and never instead of 1–8:** no
|
||
`bypass_intercept` write remains in `dired.lua` or `listview.lua`.
|
||
A structural comparison of two authorities does not catch a misrouted
|
||
consumer; keep the consumer-level assertions.
|
||
|
||
### Stage 2
|
||
|
||
1. **`M-x buffer.undo` cannot destroy `*compilation*` /
|
||
`*shell-command*` / `*search-results*` content — and the criterion
|
||
must assert the *exit marker survives*, not that the buffer is
|
||
non-empty.** *Bite, and this is the whole point:* on `ad41cf1` the
|
||
measured result of `M-x buffer.undo` on `*shell-command*` is
|
||
`[shell exited with code 0]` replaced by
|
||
`[output desynced by external edit]`. The buffer is still non-empty,
|
||
so a "not empty" assertion **passes with the bug live**. The revision
|
||
guard *marks* the corruption; it does not prevent it.
|
||
2. **A streaming run's incremental writes still land**, including CR
|
||
overwrite semantics (a progress-bar fixture) and erase-to-eol. Assert
|
||
the produced content, not the absence of an error. *Bite:* the
|
||
pre-image is the tempting half-conversion — reset via
|
||
`set_generated_contents`, stream via `bypass_intercept` — which raises
|
||
`is read-only` at the first append (measured, §2.4).
|
||
3. **The buffer is locked BETWEEN batches, not only after the run.**
|
||
Attempt an ordinary edit mid-run and require the refusal. *Bite:* a
|
||
scope-shaped implementation that unlocks for a whole run passes 1 and
|
||
2 and fails this. A state predicate, not a geometric readout.
|
||
4. **History does not accumulate across a long run:** `buf:undo()`
|
||
returns false and the contents are unchanged after N batches.
|
||
5. **`ensure_slot` leaves `*compilation*` locked before any run**
|
||
(Q#GB5). *Bite:* create the slot without running anything, then
|
||
attempt an ordinary edit; without the explicit lock it lands.
|
||
6. **`mark_clean` (Q#GB10):** `pmacs.describe.buffer(b).modified` is
|
||
`false` after a generated write. *Bite:* fails against `ad41cf1`,
|
||
where it measures `true`.
|
||
7. **Both configurations** — default and `--features crdt` — for
|
||
criteria 1–4. CRDT must not be the only home of any of them; CI never
|
||
enables the feature, and 264 tests are already dark for that reason.
|
||
8. **Structural, alongside:** zero `bypass_intercept` writes remain in
|
||
`compile.lua` and in `default.lua`'s search panel (comments excepted;
|
||
§1.1's arithmetic is the reference).
|
||
9. **The three intruder tests still assert what they were written to
|
||
assert** after being converted to a Rust-side `read_only` lift
|
||
(Q#GB12), rather than being deleted or weakened.
|
||
|
||
---
|
||
|
||
## 7. Bets
|
||
|
||
- **That a per-op `clear_history` is not a throughput problem.** Argued
|
||
from `create_undo_manager` being `UndoManager::new(doc)` (O(1) in
|
||
document size) and from at most one v0.1 entry existing per clear.
|
||
**Measured in Stage 2, not asserted here.**
|
||
- **That converting compile's nine sites does not disturb its byte
|
||
anchors.** The conversion changes *authority*, not op shape, position
|
||
or count — `emit_text`'s `slot.out_pos` arithmetic is untouched. The
|
||
bet is that nothing else in the module reads `read_only` indirectly;
|
||
criteria 2 and 3 are what test it.
|
||
- **That `{ generated = true }` sitting beside `{ bypass_intercept = true }`
|
||
is clearer than replacing it.** Q#GB2.
|
||
|
||
## 8. Deferred (named)
|
||
|
||
- **The REPL's undo exposure** (Q#GB8), with the §2.5 measurement.
|
||
- **Class C: `*buffer-list*`, `*help*`, `*workers*`** (Q#GB9).
|
||
- **wdired's unlock** (Q#GB7) — a hard prerequisite recorded onto dired
|
||
Stage 3's framing.
|
||
- **Suppress-rather-than-clear history recording**, if Stage 2's
|
||
measurement says the per-op clear costs anything.
|
||
- **`COHERENCE.md` §14's listview consumer list is wrong** (§1.5). Not
|
||
edited here; carried in the PR body.
|
||
|
||
## 9. Coherence impact (`COHERENCE.md` §20)
|
||
|
||
**Section served: §14 Coherent Workbench Primitives**, and specifically
|
||
its **Output channel** bullet, which is where this caveat is already
|
||
recorded ("*four writer mechanisms have not yet adopted it and remain
|
||
emptiable*"). This arc discharges that entry for Class A and replaces its
|
||
"a streaming variant of the primitive that does not exist yet" with one
|
||
that does. §14's list-primitive bullet is touched too: listview is called
|
||
"the strongest coherence asset in the UI layer", and it is currently
|
||
emptiable by one keystroke.
|
||
|
||
- **Priority 5 (finish the workbench convergence)** is the priority this
|
||
serves. It is a correctness debt inside an existing primitive rather
|
||
than a new primitive, so it is wiring, not model.
|
||
- **§6 interaction islands — none added.** No new keymap scope, no new
|
||
dispatch shadow, no new precedence rung. The count stays at six. This
|
||
arc deliberately does **not** add undo-chord rebindings anywhere; the
|
||
measured point of the bug is that rebinding chords was never the fix.
|
||
- **§11 configuration registry — no new settings**, no adoption change.
|
||
- **§2 golden journey** — step 6 (compile) and the dired/browse steps are
|
||
touched only in the sense that their buffers stop being destructible.
|
||
No journey step opens or closes.
|
||
- **Background-work attribution — unchanged.** `compile.lua`'s process
|
||
pump and the grep stream keep their existing ownership.
|
||
- **Protocol — no change.** Nothing new crosses the wire; the fan-out
|
||
reuses `queue_daemon_origin_crdt_op`.
|
||
- **§14 correction owed:** the listview consumer list (§1.5) and, on
|
||
merge, the handoff §4 table, whose four-row inventory is by
|
||
`bypass_intercept` and therefore misses Class C.
|
||
|
||
## 10. Verification plan
|
||
|
||
Full gate suite per `CLAUDE.md` for **each** PR separately:
|
||
|
||
```
|
||
cargo fmt --check
|
||
cargo clippy --workspace --all-targets -- -D warnings # own step
|
||
cargo test --lib
|
||
cargo test --lib --features crdt
|
||
cargo test --test <the touched acceptance suites>
|
||
cargo test --test m4_acceptance -- --skip basedpyright
|
||
PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu
|
||
git diff --check
|
||
```
|
||
|
||
Plus, per stage:
|
||
|
||
- **Stage 1** — `cargo test --test dired_acceptance` and
|
||
`--test listview_acceptance`, plus `--test terminal_copy_mode_acceptance`
|
||
if Q#GB6 lands, since the cursor clamp changes the shipped snapshot
|
||
path.
|
||
- **Stage 2** — `cargo test --test compile_mode_acceptance` **and**
|
||
`--test compile_mode_crdt_acceptance`, plus
|
||
`--test terminal_copy_mode_acceptance` (the `set_generated_contents`
|
||
reimplementation and `mark_clean` both reach it). **The search panel
|
||
has no suite of its own** — `grep -rln 'search-results' tests/` returns
|
||
only `compile_mode_acceptance.rs` and `m4_acceptance.rs`, so Stage 2's
|
||
search-panel criteria need a new home rather than an existing one to
|
||
extend.
|
||
- **Do not gate any new test on `#[cfg(feature = "crdt")]` unless it
|
||
genuinely needs CRDT.** CI never enables the feature.
|
||
- **Judge the touched suites by elapsed time as well as verdict** where
|
||
they reach for a sibling binary (`docs/agent-handoff.md` §5).
|
||
- Commit before gating: `cargo fmt` after a commit splits the worktree
|
||
from the branch and `git diff --check` will not catch it.
|