diff --git a/COHERENCE.md b/COHERENCE.md index 0efa19f..e9cb970 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -107,7 +107,7 @@ remain open to them. | 11 | Config layering + provenance | **Partial (foundation only)** | Typed registry is right; 5 settings live in it; no value provenance | | 12 | Profiles | **Missing** | One hardcoded default keymap; not a named concept | | 13 | Package lifecycle UX | **Resolution without lifecycle** | Mature resolver/lockfile; init-only install; no uninstall/disable/search | -| 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real primitive but only 3 call sites, all LSP panels; buffer-list and search re-implement it; **the bottom panel is COMPLETE — both frontends, and Stage 3 flipped the adopter default so omission means the panel**. **Tree is still ✗ and is now the arc's successor** | +| 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real primitive but only **4** call sites, all LSP panels (`*lsp*` added post-audit by #204); buffer-list and search re-implement it; **the bottom panel is COMPLETE — both frontends, and Stage 3 flipped the adopter default so omission means the panel**. **Tree is implemented (◐) with the LSP outline as its one adopter; the remaining consumers, including dired's `i`, have not adopted** | | 15 | Contextual affordances | **Weak** | Right-click menu only; code actions apply first-blindly; no git integration at all | | 16 | Semantic frontend | **Strong** | v6..=v21 schema support; production attach remains v20 during the dark panel slice; degradation practiced | | 17 | Distribution | **Partial** | **v1.1.0 ships prebuilt Linux/macOS binaries on tag** (#211) with checksums and a stated glibc floor. No channels, in-place update, rollback, signing, or package-manager distribution | @@ -1282,10 +1282,23 @@ Primitive-by-primitive against the list above: buffer-local keymap idiom (RET/SPC visit, n/p, g refresh, q quit) that is inspectable and rebindable (§6's counter-example). **But its adoption is narrower than this document claimed, and the correction - matters more than the grade.** Measured at `ad41cf1`: there are + matters more than the grade.** Measured at `ad41cf1`: there were exactly **three** `pmacs.listview.open` call sites, **all three in `builtin/runtime/lsp.lua`** — `*references*` (`:2056`), `*outline*` - (`:2102`) and `*lsp-help*` (`:2513`). The three other `listview` + (`:2102`) and `*lsp-help*` (`:2513`). + + **Updated: there are now FOUR.** Journey Stage 1b-2 (**#204**) added + `*lsp*` via `lsp.status` — the audited claim above changed and that PR + did not update it, so this correction rides the tree-primitive framing + that found it (§25). All four remain in `lsp.lua`; per §25 the + symbols are authoritative and the `ad41cf1` line numbers have drifted. + + **`*lsp*` is the only one of the four with a working refresh** — it is + the only one supplying `on_refresh`. `g` is bound on all four + unconditionally by `bind_local_keymap`, so the other three carry a + **dead refresh binding**: bound, dispatched, silently does nothing. + + The three other `listview` mentions under `builtin/` are comments in `compile.lua` and `dired.lua` citing "the listview idiom", which is a *pattern being copied*, not the primitive being used. @@ -1354,15 +1367,27 @@ Primitive-by-primitive against the list above: (§9). - **Help view** △ — exists twice (§5); needs unification, not invention. -- **Tree** ✗ — none. The named future consumers (project files, symbol - hierarchy, package dependency graph, worker trees, git status) will - each need it; building it once *before* dired's directory view and - the workers tree harden their own conventions is exactly this - section's point. Dired Stage 1 (merged #165) landed **without** inventing - one: its listing is flat (Emacs parity), and the recursive - in-buffer case — `i` insert-subdirectory — is a named deferral in - `docs/dired-framing.md` §13, which is where a shared tree primitive - would land. +- **Tree** ◐ — **implemented, one consumer.** `listview` carries + optional `depth` and `id` on rows, primitive-owned collapse state, and + selection re-seated by id rather than by line; `TAB` toggles. Absent + `depth`/`id`, a row behaves exactly as before, which is what leaves + the flat consumers untouched (pinned by byte-identity coverage). + **The LSP outline is the only adopter**: it previously flattened a + genuine `DocumentSymbol` tree into indented strings, and now supplies + structure while keeping its rendered text. + + The organising fact, worth carrying: **folding is local projection + state, not a refresh protocol.** Collapse only hides rows and never + changes a surviving row's depth, so the primitive re-renders from its + own array without calling the consumer — which is why the outline + works at all, having no `on_refresh`. + + **Still one consumer, hence ◐ not ✓.** The named future consumers + (project files, package dependency graph, worker trees, git status) + have not adopted, and dired's recursive `i` insert-subdirectory — the + second real constraint source — remains the deferral in + `docs/dired-framing.md` §13. Dired Stage 1 (merged #165) landed + **without** inventing its own, which is what kept this possible. - **Structured table / inspector / diff view** ✗ — none. (`describe.*` tables are the inspector's data model without a view; the wire-declared `ResourceOffer` family was reserved for diff/blame @@ -1669,9 +1694,19 @@ and §18's floor ride on this. **State: the bottom panel is DONE (§14) — both frontends, Stage 1 #155 through Stage 2B-3, and Stage 3 flipped the adopter default so omitting -`display` means the panel. Arc 7 is complete.** Remaining elsewhere: the tree primitive (build it before dired -and the worker tree invent two), table/inspector/diff, help unification. -Wiring plus one modest model piece (the tree model). +`display` means the panel. Arc 7 is complete.** + +**The tree primitive is IMPLEMENTED too (§14, ◐)** — `listview` carries +optional `depth`/`id`, primitive-owned collapse, and selection re-seated +by id. It was built before dired's recursive view and the worker tree +could invent their own, which was this priority's stated reason for +doing it early. + +**What remains is ADOPTION, not construction.** The LSP outline is the +only consumer; dired's `i` insert-subdirectory is the next real +constraint source, and DAP's variables view is why the primitive was +worth building first. Also remaining: table / inspector / diff, and help +unification. ### Priority 6: Productize configuration diff --git a/builtin/runtime/listview.lua b/builtin/runtime/listview.lua index 62b0cb3..a10cf57 100644 --- a/builtin/runtime/listview.lua +++ b/builtin/runtime/listview.lua @@ -114,21 +114,148 @@ end -- `M-x buffer.undo` did too, and no rebinding can remove that. The -- primitive lifts the rope lock, writes, discards the history and -- re-asserts the lock, all inside one registry borrow. +-- Tree support (docs/tree-primitive-framing.md, Q#TR1-TR4). +-- +-- A row MAY carry `depth` (0-based, structural) and `id` (a STRING or +-- NUMBER, consumer-supplied, compared by value). Both optional: a row +-- without them behaves exactly as before, which is what keeps the +-- three flat consumers byte-identical. +-- +-- `text` stays CONSUMER-RENDERED (Q#TR4). The primitive owns structure, +-- not presentation -- collapse only ever HIDES rows and never changes a +-- surviving row's depth, so pre-rendered indentation remains correct +-- and the primitive never has to re-format anything. +-- +-- Descendants are a CONTIGUOUS RUN of following rows with greater +-- depth. That holds because consumers emit parents before children in +-- document order (the LSP outline's `Symbol` ordering guarantees it); +-- a consumer that emits depth out of order gets nonsense, which is why +-- `has_children` reads only the NEXT row rather than scanning. +local function has_children(rows, i) + local d = rows[i].depth + if not d then return false end + local nxt = rows[i + 1] + return nxt ~= nil and (nxt.depth or 0) > d +end + +-- Is `rows[i]` hidden because some ANCESTOR is collapsed? +-- +-- Walks backwards to shallower rows, which is the ancestor chain under +-- the contiguous-run invariant above. Stops at depth 0: a root has no +-- ancestor to hide it. +local function hidden_by_ancestor(p, rows, i) + local d = rows[i].depth + if not d or d == 0 then return false end + local want = d - 1 + for j = i - 1, 1, -1 do + local dj = rows[j].depth or 0 + if dj <= want then + if rows[j].id ~= nil and p.collapsed[rows[j].id] then return true end + want = dj - 1 + if want < 0 then return false end + end + end + return false +end + +-- Ids must be usable, unique table keys, and none of the three checks +-- below is fussiness about types. +-- +-- SCALAR. Selection compares ids with `==`, which honours `__eq`; +-- collapse state stores them as TABLE KEYS, and Lua indexes tables by +-- raw identity, consulting no metamethod. A table id would therefore +-- satisfy one and quietly fail the other: after a refresh minted fresh +-- id tables, selection would be restored and the fold would be lost. +-- +-- Equality-aware collapse lookup is the alternative, and it is worse +-- here: `hidden_by_ancestor` runs per row and would turn a linear +-- render quadratic to support a key type no consumer has wanted. +-- +-- NOT NaN. `0/0` passes a `type(x) == "number"` test and then *errors* +-- at `p.collapsed[row.id]` with "table index is NaN" — the one scalar +-- Lua accepts as a number and refuses as a key. Caught here so the +-- report names the row, rather than surfacing on whichever later TAB +-- happens to reach it. +-- +-- UNIQUE. Every lookup here resolves an id to the FIRST row bearing +-- it, so duplicates do not merely collide: selecting the second such +-- row toggles the first and re-seats the cursor onto it. An id that +-- does not identify a node is not an id, and the contract's word for +-- itself is identity. +-- +-- All three are enforced where rows enter, so a bad id is a named +-- error at the call site instead of a lost fold or a stray jump later. +local function check_ids(rows) + local seen = {} + for i, row in ipairs(rows) do + local id, k = row.id, type(row.id) + if id ~= nil then + if k ~= "string" and k ~= "number" then + error(string.format( + "listview: row %d has a %s id; ids must be a string or number " + .. "(collapse state keys a table by identity, so a %s id would " + .. "lose its fold across a refresh)", i, k, k)) + end + if id ~= id then + error(string.format( + "listview: row %d has a NaN id; NaN is a number but not a " + .. "usable table key, and collapse state would raise " + .. "\"table index is NaN\" on the first fold", i)) + end + if seen[id] then + error(string.format( + "listview: rows %d and %d share the id %q; ids must be unique " + .. "(every lookup resolves to the first match, so selecting " + .. "the later row would toggle and re-seat the earlier one)", + seen[id], i, tostring(id))) + end + seen[id] = i + end + end + return rows +end + local function render(p, rows) local lines = { p.header } + p.visible = 0 p.line_to_item = {} - for _, row in ipairs(rows) do - lines[#lines + 1] = row.text - p.line_to_item[#lines - 1] = row.item + p.line_to_row = {} + for i, row in ipairs(rows) do + if not hidden_by_ancestor(p, rows, i) then + lines[#lines + 1] = row.text + -- SPARSE BY CONSTRUCTION: `item` is optional, and a display-only + -- row (a grouping header in a tree, say) supplies none, so this + -- key is simply absent for it. Nothing may take `#` of this + -- table; `visible` below is the row count. + p.line_to_item[#lines - 1] = row.item + p.line_to_row[#lines - 1] = row + p.visible = #lines - 1 + end end pmacs.buffer.set_generated_contents(p.buffer, table.concat(lines, "\n")) end +-- The data line currently showing `id`, or nil. Selection is re-seated +-- BY ID rather than by line (Q#TR3): a collapse or expand inserts or +-- removes rows above the cursor, so a line-keyed restore lands on an +-- unrelated node. +local function line_of_id(p, id) + if id == nil then return nil end + for line, row in pairs(p.line_to_row) do + if row.id ~= nil and row.id == id then return line end + end + return nil +end + -- Re-seat the cursor on data line `line` (1-based, clamped). -- `switch_active_buffer` zeroes the window cursor, so a fresh switch -- puts us on the header; walk down from there. local function seat_cursor(p, line) - local count = #p.line_to_item + -- `p.visible`, NOT `#p.line_to_item`: that map is sparse whenever a + -- row omits the optional `item`, and `#` on a sparse table is not + -- the row count. Reading it there left a tree of display-only rows + -- with the cursor stranded on the header, where TAB finds no node. + local count = p.visible or 0 if count == 0 then return end local target = math.max(1, math.min(line or 1, count)) for _ = 1, target do @@ -146,6 +273,7 @@ local function bind_local_keymap(buf) bind("", "cursor.down") bind("p", "cursor.up") bind("", "cursor.up") + bind("TAB", "listview.toggle") bind("g", "listview.refresh") bind("q", "listview.quit") end @@ -186,7 +314,8 @@ local function ensure_panel(name) end local buf = pmacs.buffer.create(actual) - p = { requested_name = name, buffer = buf, line_to_item = {} } + p = { requested_name = name, buffer = buf, line_to_item = {}, + line_to_row = {}, collapsed = {}, rows = {}, visible = 0 } panels[#panels + 1] = p -- Read-only (Q#P3): every non-bypass edit is rejected, with a NAMED -- error. Kept beside the rope lock, not replaced by it: the layering @@ -219,7 +348,12 @@ function pmacs.listview.open(spec) if active and not panel_for_buffer(active) then p.prev = active end - render(p, spec.rows or {}) + -- Keep the row array: collapse re-renders from it WITHOUT calling the + -- consumer, which is what lets a panel with no `on_refresh` still + -- expand and collapse (the outline has none -- framing §1.5a). + p.rows = check_ids(spec.rows or {}) + p.collapsed = {} + render(p, p.rows) -- Bottom-panel arc (Q#BP11b): the placement opt-in. `seat_cursor` and -- `listview.refresh` are active-window-only, so an interactive panel -- MUST take `select = true` or it would silently seat the wrong @@ -261,7 +395,12 @@ pmacs.command.define { local p = active_panel() if not (p and p.on_refresh) then return end local saved = pmacs.editor.cursor_line() - local rows = p.on_refresh() or {} + -- Q#TR3: remember the NODE, not the line. A refresh that changes + -- the row set moves every line; the id survives it. + local saved_row = p.line_to_row[saved] + local saved_id = saved_row and saved_row.id + local rows = check_ids(p.on_refresh() or {}) + p.rows = rows render(p, rows) -- `set_generated_contents` has already refreshed this window's -- TextView. Re-seat through the editor primitives instead of @@ -270,7 +409,63 @@ pmacs.command.define { pmacs.editor.clear_selection() pmacs.editor.set_view_top(0) pmacs.editor.move_to_line(0) - seat_cursor(p, saved) + seat_cursor(p, line_of_id(p, saved_id) or saved) + end, +} + +-- TAB toggles the node under the cursor. A leaf is a no-op with a +-- status, never a silent nothing -- the outline's `g` is already a +-- dead binding that responds to nothing (framing §1.3a) and this +-- primitive should not add a second one. +pmacs.command.define { + name = "listview.toggle", + description = "Collapse or expand the tree node under the cursor.", + fn = function() + local p = active_panel() + if not p then return end + -- A FLAT panel must keep its pre-tree TAB behaviour exactly. + -- + -- `bind_local_keymap` binds TAB for every listview, so this command + -- now intercepts a key that previously fell through to the global + -- `buffer.tab` and was refused by the Q#P3 read-only intercept. + -- Emitting a listview status instead would be a behaviour change + -- for the three flat consumers -- invisible to a byte-identity test, + -- which sees the buffer and not the status line or the dispatch + -- path. So a panel with no tree rows at all delegates. + local is_tree = false + for _, r in ipairs(p.rows) do + if r.id ~= nil then is_tree = true break end + end + if not is_tree then + pmacs.command.invoke("buffer.tab") + return + end + + local line = pmacs.editor.cursor_line() + local row = p.line_to_row[line] + if not (row and row.id ~= nil) then + pmacs.editor.set_status("listview: no node here") + return + end + -- `has_children` reads the FULL row array, not the rendered subset: + -- a collapsed node's children are absent from `line_to_row` by + -- construction, so asking the rendered view whether it has any + -- would answer "no" for every collapsed node and make expanding + -- impossible. + local idx + for i, r in ipairs(p.rows) do + if r.id ~= nil and r.id == row.id then idx = i break end + end + if not (idx and has_children(p.rows, idx)) then + pmacs.editor.set_status("listview: no children") + return + end + p.collapsed[row.id] = not p.collapsed[row.id] or nil + render(p, p.rows) + pmacs.editor.clear_selection() + pmacs.editor.set_view_top(0) + pmacs.editor.move_to_line(0) + seat_cursor(p, line_of_id(p, row.id) or line) end, } diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index 6107b38..eb4bb25 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -2480,15 +2480,28 @@ function pmacs.lsp.document_symbols() for _, sym in ipairs(syms) do local tag = SYMBOL_KIND_TAGS[sym.kind] or "symbol" rows[#rows + 1] = { + -- Tree primitive (docs/tree-primitive-framing.md): `depth` is + -- STRUCTURAL and `text` stays rendered here (Q#TR4). Collapse + -- only hides rows and never changes a surviving row's depth, so + -- the indentation below remains correct without the primitive + -- re-formatting anything. + -- + -- `id` is line:col (Q#TR3) — unique per document and stable + -- across a re-render. The `::` parent chain was rejected: it + -- collides on overloads and same-named siblings, which is + -- exactly where a stale expansion would reattach to the wrong + -- node. text = string.format( "%s%s [%s]", string.rep(" ", sym.depth or 0), sym.name, tag), item = sym, + depth = sym.depth or 0, + id = string.format("%d:%d", sym.line, sym.col), } end pmacs.listview.open { name = "*outline*", header = string.format( - "%d symbol%s RET visit n/p move q quit", + "%d symbol%s RET visit TAB fold n/p move q quit", #syms, (#syms == 1 and "" or "s")), rows = rows, on_visit = function(sym) diff --git a/docs/active-work.md b/docs/active-work.md index 5a76083..4c596b4 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -19,10 +19,21 @@ until review caught it — the #171 defect recurring. Its block is below. **Updated later the same day.** #215 **merged** (`main` @ `12f2970`) and that lane is **rewritten, not removed** — rule 4 removes a lane when its -ARC is done, and Stage 2 is the arc. Stage 2 (**hardening**) is now in -flight on `ci-signal-hardening`, with its own lane block, its own -checkpoint table, and a lane written **before** the PR was opened rather -than after review asked for it. +ARC is done, and Stage 2 is the arc. Stage 2 (**hardening**) ran on +`ci-signal-hardening`, with its own lane block, its own checkpoint +table, and a lane written **before** the PR was opened rather than +after review asked for it. **It merged as #216 on 2026-08-05**, which +completes the arc: R2 and R4 are retired with discriminating +witnesses. + +**The lane is kept, not yet removed, and that is a deferral rather than +a judgement.** Rule 4 would remove it now — but its residue must be +re-homed first, or removing it loses the state: **R1** is referred to +the async-runtime lane (Q#MCI3) and **R3** is an unresolved possible +product defect owned by the process-signal / PTY lane, neither of which +has a block here yet. Retiring this arc and opening those two is an +**absorption pass**, and doing it inside an unrelated feature PR is how +a ledger acquires edits nobody reviewed. **Updated 2026-08-04.** Four PRs landed since: the CI CRDT coverage lane #209, Distribution Stage 1 #211 (released as **v1.1.0**), the @@ -169,7 +180,128 @@ form. All four steps ran clean. **The two-argument form still does not work** for a remote-only branch (`fatal: invalid reference`), which is why every lane below spells out the `-b` form. -## macOS CI signal integrity — STAGE 1 MERGED (#215); STAGE 2 IN REVIEW +## Tree primitive (P5) — PR #217 OPEN, awaiting review + +**PR #217** — https://github.com/levineuwirth/pmacs/pull/217. It was +held through four framing rounds plus a review round; the hold is over +and the work is with the user. Full gate suite green at the tip, +including `PMACS_REQUIRE_GPU=1 -p pmacs-gpu` 221/0. + +- **Branch `tree-primitive-framing`**, base `githubsucks/main` @ + `12f2970`. **Pushed and in sync** — held means no PR is open, not + that the work is stranded locally, so it is portable per the + handoff's rule. `githubsucks/tree-primitive-framing` is the + authoritative tip; `d92f0ad` was it as of the line above, and any + edit to this lane advances past it, which is why the ref and not a + pinned SHA is the thing to trust. Recover with `git fetch githubsucks + && git checkout tree-primitive-framing`. Framing + `docs/tree-primitive-framing.md` **revision 5** — approved after four + review rounds, with Q#TR1–TR4 decided. +- **The substantive arc** (not an exhaustive log — read `git log + 12f2970..githubsucks/tree-primitive-framing` for that): `61b1062` + framing through `5186bfd` rev 4, which carried the `COHERENCE.md` §14 + call-site correction; `49a42ec` the primitive; `8f64c3b` + byte-identity coverage plus the verification record; `38e94dc` this + lane, §14's ✗ → ◐ and the handoff facts; `c59de95` the review round — + moving-selection witness, flat-panel TAB delegation, §20's build → + adopt correction. + +### What it ships + +`listview` gains **optional** `depth` and `id` on rows; absent, a row +behaves exactly as before, which is what leaves the flat consumers +untouched. Collapse state is **primitive-owned**, keyed by +consumer-supplied id. Selection is re-seated **by id, not by line**. +`TAB` toggles; a leaf reports rather than silently doing nothing. + +**The observation that made it cheap:** collapse only ever *hides* rows +and never changes a surviving row's depth, and consumers emit parents +before children, so descendants are a **contiguous run**. Folding is +therefore **local projection state, not a refresh protocol** — the +primitive re-renders from its own array without calling the consumer, +which is why the anchor consumer works at all: **the outline has no +`on_refresh`**. + +The LSP outline adopts, supplying `depth` and `id = line:col`; its +`text` stays consumer-rendered per Q#TR4. + +### Verification + +| gate | result | +|---|---| +| luajit sweep | **3453 / 0** (= `main` 3450 + 3 listview tests) | +| crdt sweep, isolated ×2 | **3722 / 0** (= `main` 3718 + 4 tests) | +| `listview_acceptance` | 22 / 22 | +| `m4_acceptance` | 150 / 150 (basedpyright skipped) | +| `--lib` | 1896 / 0 | +| fmt · diff-check · clippy ±crdt | pass | + +Both behavioural claims are **bite-verified**: disabling the ancestor +filter fails the fold test; an unconditional gutter fails the +byte-identity test. + +**One unclassified occurrence** — the first crdt sweep reported 7 +failures whose signatures were destroyed before being read. It is +recorded in the framing's §6a, **not** as a row in +`docs/ci-red-signatures.md`, because it has no normalized signature to +match. Two non-causal hypotheses are recorded there; neither is testable +now. + +### Recovery, once pushed + +```sh +git fetch githubsucks +git worktree add ../pmacs-tree \ + -b tree-primitive-framing \ + githubsucks/tree-primitive-framing +``` + +### Not in scope + +dired's `i` insert-subdirectory (the second consumer, its own stage); +the other four §14 consumers; DAP's variables view; giving the outline a +refresh, which is LSP request-lifecycle work. + +## Leaked daemons from `gpu_invocation_acceptance` — NEEDS A LANE + +**Found 2026-08-05 while cleaning up after the tree-primitive work. No +branch, no framing.** + +- **42 orphaned `pmacs --daemon` processes** were resident on the + development machine, **the oldest 3 days 23 hours old**. All had been + **reparented to systemd** (`ppid=1`) and all had **deleted sockets**, + so nothing could ever reach or reap them. +- **Source: `tests/gpu_invocation_acceptance.rs`** — the one-command + tests, whose daemons carry `--socket /one-command.sock`. The + tempdir is cleaned up; the daemon is not. +- **Rate measured, not estimated: 3 per sweep.** A single isolated + `--features luajit,crdt` sweep leaked exactly three. 42 is what + several days of sweeps accumulate to. +- **This predates the tree work** — the oldest is four days old — so it + is a standing leak, not something a current lane introduced. + +**Why it belongs to the reap-ledger family.** This is precisely the +shape that lane exists for: a process that outlives its supervisor with +nothing left watching it. The ledger arms only for `spec.group`, and +these are daemons spawned by a test harness rather than by compile mode, +so **nothing in the existing ledger covers them**. + +**Why it matters beyond tidiness.** Dozens of resident daemons were +present during every local sweep run this week, including the one that +produced the unclassified failure recorded in the **tree-primitive lane +above** (and, in full, in that lane's framing §6a). That +makes them a **rival explanation** to the shared-target-dir mechanism +for that occurrence, and neither can be tested against it now — the +signatures were not captured. A leak that quietly changes the +environment of every subsequent test run is a measurement problem as +well as a resource one. + +**First questions for whoever takes it:** does the test harness fail to +reap, or does the daemon fail to exit when its socket disappears? Those +have different fixes, and the second would be a product defect rather +than a test one. + +## macOS CI signal integrity — STAGES 1 AND 2 MERGED (#215, #216) **This file requires a lane for every open PR** (see the #171/#174 note above: an open PR is exactly the volatile work this file records, and @@ -180,16 +312,18 @@ which is where the same defect stops recurring. Framing `docs/macos-ci-signal-integrity-framing.md` **revision 3**, already approved. Stage 1 is acceptance 1–5; Stage 2 is acceptance 6–9. -### Stage 2 — hardening, IN REVIEW +### Stage 2 — hardening, MERGED as #216 - **Branch `ci-signal-hardening`**, worktree `../pmacs-ci-signals`, base `githubsucks/main` @ `12f2970` (the #215 merge). Opened from an **isolated worktree** because the shared checkout was on another lane's branch with clean-but-foreign state; never switch it. -- **PR: **. This block - was written *with* the work, before the PR existed, so the row below - was filled in rather than invented. **Checkpoints, newest last**, - because a lane that records only one head goes stale on the next push: +- **PR: , MERGED** + 2026-08-05 as `2657568`, all 14 checks green at head `8ab20b5`. This + block was written *with* the work, before the PR existed, so the row + below was filled in rather than invented. **Checkpoints, newest + last**, because a lane that records only one head goes stale on the + next push: | head | CI run | result | |---|---|---| diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index c9640fd..2b3e3c8 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -124,6 +124,36 @@ commands, read `docs/active-work.md` immediately after this file. - **A capability fallback must strip the QUIT ACTION too**, not just the side parameters — a quit action stranded on a document window makes a later `q` try to restore a presentation that never happened. +- **The tree primitive ships — `listview` gained depth, collapse and + identity** (P5, §14's last missing workbench primitive; implemented, + PR held). Rows carry **optional** `depth` and `id`; absent, a row + behaves exactly as before, which is what leaves the flat consumers + untouched. The LSP outline is the one adopter. Durable facts: + - **Folding is local projection state, not a refresh protocol.** + Collapse only *hides* rows and never changes a surviving row's + depth, and consumers emit parents before children, so descendants + are a **contiguous run**. The primitive therefore re-renders from + its own array **without calling the consumer** — which is the only + reason the anchor consumer works, because **the outline has no + `on_refresh` at all**. A design requiring the consumer to re-supply + rows on every fold would have fitted no existing consumer. + - **Identity is consumer-supplied and compared by equality; the + primitive never derives one.** `item` is opaque by design. The + outline uses `line:col`, because the `::` parent chain collides on + overloads and same-named siblings — exactly where a stale expansion + would reattach to the wrong node. **Selection is re-seated by id, + not by line**, since a fold inserts or removes rows above the + cursor. + - **`has_children` must read the FULL row array, not the rendered + subset.** A collapsed node's children are absent from the rendered + map by construction, so asking the view would answer "no" for every + collapsed node and make expanding impossible — a self-sealing bug + that looks like fold working and unfold silently not. + - **A bite that passes validates the pair, not the test.** The first + byte-identity injection used `row.depth or 0`; flat rows have no + depth, so it changed nothing and the test "passed" against a + regression the flat path is immune to. Ask which defect you + injected before believing a green bite. - **pmacs is installable without cloning — Distribution Stage 1, #211, released as v1.1.0.** A `v*` tag builds `pmacs` and `pmacs-gpu` on pinned `ubuntu-22.04` / `macos-15` and publishes a GitHub Release with @@ -228,7 +258,7 @@ anchor, so every item is startable. | 2 | Workspace + location | Missing; model gap | The long-lead arc. Start before a fifth subsystem grows its own root convention — four have already diverged (§7) | | 3 | Extension ownership | Missing; prerequisite-shaped | **`pmacs.hook.remove` does not exist.** That one bug-sized gap blocks §13's disable/uninstall, §10's trust classes, and package-scoped cancellation | | 4 | **Discovery** | **Stage 1 MERGED (#207)** | Stage 2 candidates, in rough dependency order: richer M-x rows (**protocol change** — `MinibufferPrompt.candidates` is `Vec`; `CompletionPopupRow` already proves the pattern), `Command` gaining title/category/aliases/flags/arg-schema (~147 definition sites), predicate evaluation, help-layer unification, and the help-prefix decision | -| 5 | Workbench convergence | Partial; **Arc 7 COMPLETE** (Stage 3 merged, #213) | The bottom panel is finished on both frontends and the adopter default is flipped. **The tree primitive is now the arc's successor** — `COHERENCE.md` §14 grades Tree ✗, and DAP's variables view is its next would-be inventor. Build it *before* dired's `i` and the worker tree invent two | +| 5 | Workbench convergence | Partial; **Arc 7 COMPLETE** (#213) and **the tree primitive is implemented** (PR held) | The bottom panel is finished on both frontends and the adopter default is flipped. The tree primitive has landed on a held PR: §14's Tree moves ✗ → ◐ with the LSP outline as its one adopter. **Next: adoption** — dired's `i`, then DAP's variables view, which is why it was built first | | 6 | Config productization | Foundation only | Value provenance, then layering, then adoption migration (**table-valued settings are the hard prerequisite** — `ConfigValue` is four scalars) | | 7 | Package lifecycle | Not started | Correctly sequenced after P3 | | 8 | **Distribution** | **Stage 1 SHIPPED (v1.1.0, #211)** | Binaries on tag, checksums, machine-checked glibc floor. **Journey step 1 now works and the "invisible until this exists" blocker is lifted.** Next is a *decision* about channels / update / signing, not a queued plan | @@ -317,18 +347,34 @@ someone forgot. `gpu_invocation_acceptance` tests fail on a missing `pmacs-gpu` binary. `cargo build --workspace --no-default-features --features luajit,crdt` is the invocation that produces both binaries. -- **A shared `CARGO_TARGET_DIR` makes a local sweep unattributable.** +- **A shared `CARGO_TARGET_DIR` makes concurrent sweeps unattributable.** Every worktree on this machine resolves to the same target directory, - so `target/debug/pmacs` is a **shared mutable file**: a concurrent - `cargo test --workspace` in another worktree, at default (non-`crdt`) - features, overwrites the binary that a running `crdt` sweep is - spawning. That produced seven failures in three real-daemon suites - during the Stage 2 hardening sweep while the baseline was clean, and - the failure text named its own cause — *"start the daemon built with - the `crdt` feature"*. Confirmed with `pgrep` and discriminated by - re-running from the same tree with a **dedicated** - `CARGO_TARGET_DIR`. **Check for other live worktrees before believing - a sweep failure, and give the sweep its own target directory.** + so `target/debug/pmacs` is a **shared mutable file**: a + `cargo test --workspace` at default features in one worktree + overwrites the binary a running `crdt` sweep is spawning, and every + real-daemon suite then starts the wrong one. **Established once, on + 2026-08-05, in the Stage 2 hardening sweep** — seven failures across + three real-daemon suites against a clean baseline, the failure text + naming its own cause (*"start the daemon built with the `crdt` + feature"*), `pgrep` confirming the rival build, and a re-run from the + same tree with a **dedicated** `CARGO_TARGET_DIR` giving 41/41. The + reciprocal case was seen from the other side the same day. + **Give a second worktree its own target dir**, check for live + worktrees before believing a sweep failure, and treat any red from a + sweep that overlapped another build as unattributable rather than as + evidence. *(This mechanism does NOT retroactively explain the tree + lane's unclassified occurrence — that one's signatures were destroyed + before being read, so it has no captured text to match against this + one's, and it keeps two non-causal hypotheses. A mechanism + established in one occurrence is not evidence about a different + occurrence that was never characterized.)* +- **A local sweep leaks daemons, and they accumulate across days.** + `gpu_invocation_acceptance`'s one-command tests leave ~3 orphaned + `pmacs --daemon` processes per sweep, reparented to systemd with + deleted sockets; 42 were resident at one point, the oldest four days + old. They are a rival explanation for any load-sensitive local + failure, so **check `pgrep -f "pmacs --daemon"` before trusting a + local red**. Lane recorded in `docs/active-work.md`. - **A local sweep is blind to whichever feature configuration it does not build.** Stage 3's census and every verification sweep ran `--features luajit` WITHOUT `crdt`, so no crdt-gated suite was @@ -2033,7 +2079,20 @@ before trusting them: `m6_8_supervisor_reaps_all_children_across_cycles`) are timing-based; `editor::composition_overhead_under_ten_percent` is a render-ratio microbenchmark that fails ~1/3 even isolated single-threaded (already - `cfg!(macos)`-disabled). Vterm Stage 3's merge CI saw one macOS timeout in + `cfg!(macos)`-disabled). *(Local measurements, 2026-08-05, 16-core + Linux, tree-primitive branch, which could not reach it — its diff + versus `main` touches no `src/`, no crate, no manifest. **Two reds, + both inside the full `--features crdt` lib run**, at `dispatch + overhead` 30.7% and 10.3%; two full-suite runs at the same tips were + green; and **10/10 green run alone**, ratios spanning -2.3% to + +1.5%. So the in-suite/isolated split has now been seen twice, and + the second red cleared the 10% budget by 0.3 points — the threshold + is marginal, not comfortably clear. Recorded as measurements, NOT a + cause: ten isolated greens establish that the ratio sits nowhere near + the threshold when alone, not that in-suite contention is what pushed + it over, and two reds against two greens in-suite is intermittence + rather than a mechanism. Not a `ci-red-signatures.md` row either — + that registry judges red **CI** runs, and these were local.)* Vterm Stage 3's merge CI saw one macOS timeout in `real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell`; the complete failed-job rerun passed. The required-GPU gate also failed once in `headless_diag_face_recolors_band_counter_despite_unchanged_text`, then diff --git a/docs/tree-primitive-framing.md b/docs/tree-primitive-framing.md new file mode 100644 index 0000000..6e58d9b --- /dev/null +++ b/docs/tree-primitive-framing.md @@ -0,0 +1,594 @@ +# Framing — the tree primitive + +**Revision 5.** Status: **IMPLEMENTED and GATED; PR HELD for review.** +Q#TR1–TR4 decided at approval. Both sweeps green and both behavioural +claims bite-verified — see §6a. Scouted against +`githubsucks/main` @ `12f2970`. Carries a correction to `COHERENCE.md` +§14 (revision 4, below). + +**Revision 4 → 5** records the four decisions and makes acceptance final. + +**The decision that made the others cheap:** collapse only ever *hides* +rows — it never changes a surviving row's depth. Combined with §1.1's +document order (**parents before children**), a node's descendants are a +**contiguous run of following rows with greater depth**. So collapse is +**filtering an existing array**, not re-deriving one. The primitive +therefore never calls the consumer to re-render a collapse, and +pre-rendered indentation stays correct — which is why Q#TR4 resolves +toward the consumer keeping `text`. + +| question | decision | +|---|---| +| **Q#TR1** | **Extend `listview`.** A separate `treeview` would either duplicate ~200 lines of panel discipline (Q#GB18 handle identity, Q#GB13 `<2>` disambiguation, the read-only intercept, `prev` capture, quit chain, generated-buffer writes) or require *extracting* them from a shipped primitive first — the riskier change. Extending is backward-compatible by construction: absent `depth`/`id` give today's behaviour exactly, which the three flat consumers already produce. | +| **Q#TR2** | **Primitive-owned collapse state**, keyed by row id, held in the panel record. Consumer-owned would make every consumer reimplement refresh survival. | +| **Q#TR3** | **Consumer-supplied `row.id`**, a **string or number**, compared by value; the primitive never derives one. *(Review narrowed this from "opaque": collapse state keys a table, and Lua table indexing ignores `__eq`, so an opaque id could restore selection while silently losing its fold. `check_ids` enforces it where rows enter — and with it that ids are **unique** and **not NaN**, since every lookup takes the first match (so a duplicate makes selecting the later row toggle the earlier) and `0/0` is the one scalar Lua counts as a number and refuses as a table key.)* The outline uses **`line:col`** — unique per document and stable across re-render, where the `::` parent chain collides on overloads. | +| **Q#TR4** | **Consumer keeps pre-rendered `text`**; `depth` is structural only. Also sidesteps the conflict with dired's fixed-width `_layout` column contract when it adopts. | + +**Acceptance 5 is decided too: byte-identity coverage is written**, +including the fake-LSP harness work for `*references*`. + +**Revision 3 → 4**: + +- **"No `g`" was literally false**, in revisions 2 and 3 both. + `bind_local_keymap` binds `g → listview.refresh` on **every** panel + unconditionally (`listview.lua:147`). What three of the four lack is + an `on_refresh`. The §1.3a table now separates **`g` bound**, + **refresh advertised** and **refresh functional**, because those are + three different facts and I had been collapsing them into one. + Consequence worth noting on its own: the outline has a **dead refresh + binding** — `g` is dispatched and silently does nothing, with no + status message. +- **`COHERENCE.md` §14 is corrected in this branch**, not deferred. + §25 requires an audited claim to be updated by the PR that changes + it; **#204 changed it and missed it**, so the correction rides the + framing that found it. The `ad41cf1` audit fact is retained as + history, with the current count of four and `*lsp*` named as the + post-audit addition. The §0 scorecard row moves with the body, since + it carried the same "3 call sites". + +**Revision 2 → 3**, three further review findings, all verified in +source: + +- **`*references*` has no `on_refresh`** — revision 2 claimed it had + refresh, twice. The consumer with refresh is **`*lsp*`** (§1.5a). The error + came from reading `listview.lua`'s **module-docstring example**, which + illustrates the API using `*references*` with `g refresh` in the + header. An example is not a consumer. *The refresh-scoping conclusion + is unaffected — it depended on the OUTLINE lacking refresh, which + holds.* +- **The branch plan still said "references or buffer-list"** — the same + `*buffer-list*` error acceptance 5 had already been corrected for, + surviving one section further down. +- **"Byte-identically, pinned by their existing suites" was + unsupported.** `listview_acceptance` states in its own header that + `*references*` needs a live LSP and is not exercised there, and the m4 + hover test asserts content *presence*, not byte-exact output. + Acceptance 5 now poses that as a decision — write the byte-identity + test, or weaken the claim — with a leaning and its cost. + +**Found while verifying those: §14's "exactly three call sites" is +stale — there are FOUR** (§1.3a), and the fourth (`*lsp*`, added by +#204) is the only one with refresh. + +**Revision 1 → 2**, all from review, all verified against source before +applying: + +- **Refresh was unscoped for the anchor consumer.** Two criteria rested + on `g` refresh; the outline has no `g`, no `on_refresh`, and + `listview.refresh` no-ops without one. Refresh is now **out of scope**, + with the LSP re-request question it actually raises stated (§1.5a). +- **Acceptance 1 decided Q#TR4 while calling it open** — "no + `string.rep` in `lsp.lua`" commits to primitive-owned indentation. It + is representation-neutral now. +- **Q#TR1 misread §14.** A tree is not the "second primitive" §14 warns + against; §14 **explicitly lists one**. The warning is against bespoke + per-consumer plumbing. Retradeoffed without prejudging. +- **The regression criterion named the wrong consumers.** + `*buffer-list*` and project search do **not** use listview — §14 says + so and calls the older claim an error. The real siblings are + `*references*` and `*lsp-help*`. +- **Consumer accounting tightened**: five named future consumers, not + six; **one** existing anchor plus **one** future constraint source, + not "two that exist"; and "every input a tree needs" qualified, since + stable identity is exactly what is missing. + +`COHERENCE.md` §14 grades **Tree ✗ — none**, and it is the last missing +workbench primitive. §20's Priority 5 names it as what remains after the +bottom panel, and its argument is specific: *"building it once before +dired's directory view and the workers tree harden their own conventions +is exactly this section's point."* + +**This document deliberately narrows that argument.** §14 names five +future consumers — project files, symbol hierarchy, package dependency +graph, worker trees, git status — and designing a shared primitive +against five hypothetical ones is how you get a model that fits none. + +The scout found a narrower and firmer basis, and the distinction between +its two halves matters: + +- **One EXISTING anchor consumer.** The LSP outline already ships a + tree and fakes it (§1.1). It is the only consumer that exists today. +- **One FUTURE constraint source.** dired's `i` insert-subdirectory is + scoped and deliberately deferred (§1.2). It constrains the design; it + does not validate it, because nothing has been built against it. + +Calling these "two consumers that exist" would overstate the evidence by +exactly one. + +--- + +## 0. Coherence impact (COHERENCE §20) + +- **Concern: §14 Coherent Workbench Primitives.** Tree is the one + remaining ✗ in its inventory. This closes it for the **one consumer + that exists** (the LSP outline) and gives the rest an adoption path. +- **Journey steps touched:** none directly. Step 6 (LSP) gains a real + hierarchy view where it currently has indented text. +- **Interaction islands (§6): NONE, unless evidence forces one.** The + default is the ordinary **buffer-local keymap** idiom that listview, + compile, dired and terminal already use — a tree's expand/collapse + keys are buffer-local bindings on a generated buffer, not a new + dispatch shadow. §6 grades islands "weak, and growing"; this stage + must not add to that count. If some behaviour genuinely cannot be + expressed as a buffer-local binding, that is a finding to report, not + a licence to add an island. +- **Config registry adoption:** none proposed. If a preference emerges + (initial expansion depth, say), it enters the registry rather than + becoming a hardcoded constant — but nothing yet requires one. +- **Background-work attribution:** none. +- **Enables:** DAP's variables view, which is inherently a tree + (scopes → objects → fields) and would otherwise become the **third** + bespoke implementation. + +--- + +## 1. Ground truth (measured at `12f2970`) + +### 1.1 The LSP outline is a shipped tree consumer, faking it + +**The hierarchy already exists and is already discarded.** + +`Symbol::push_hier` (`src/symbol.rs:110`) walks a genuine LSP +`DocumentSymbol` tree — it recurses on `item.get("children")` — and +**flattens it** into `Vec`, preserving: + +- `depth: u32` — "Nesting depth in a hierarchical `DocumentSymbol` tree"; +- `parent` — `containerName` for flat shapes, or the parent chain joined + with `::` for hierarchical ones; +- document order, **parents before children** (`SymbolResponse.symbols`). + +`lsp.lua` then re-renders that depth as *leading spaces inside the row +text*: + +```lua +-- Arc 1b phase 2: a browsable *outline* panel. Symbols arrive +-- FLAT with a `depth` field --- indent, don't recurse. +text = string.format("%s%s [%s]", string.rep(" ", sym.depth or 0), sym.name, tag), +``` + +So the outline has **no collapse, no expand, no parent/child +navigation** — indentation is a string. + +**Every input a tree needs to RENDER is already computed** — depth, +parent, and order — and only the view throws it away. That is not the +same as every input a tree needs: **stable node identity is missing** +(§1.5, Q#TR3), and it is the one input no existing field supplies. + +**This is the anchor consumer.** It needs no new data plumbing, and its +limitation is observable today rather than hypothetical. + +### 1.2 dired declined to invent one, and its case is already scoped + +Dired Stage 1 landed a **flat** listing for Emacs parity and deferred +the recursive case explicitly: `docs/dired-framing.md` §13 names +**`i` insert-subdirectory (in-buffer recursive listing)** as deferred. + +§14 credits this directly: dired "landed **without** inventing one". That +restraint is what keeps the door open — and it means this constraint +source carries real requirements rather than a wishlist: a fixed-width +column contract (`pmacs.dired._layout`), a frozen test fixture, and +path-keyed entries. + +### 1.3 The workers view is NOT a consumer yet + +`editor.list-workers` is +`pmacs.window.switch_buffer(pmacs.workers.show())` — a **Rust-generated +text buffer, raw-switched** into the active window. It is not a listview +and has no rows. §14's "worker trees" is a future consumer, not a +current one. + +*(Incidental, and worth a separate look: that raw switch is the same +`switch_buffer` pattern that broke the outline under bottom-panel Stage +3. It is harmless while `*workers*` is not a panel, and inherits the +hazard the moment it becomes one.)* + +### 1.3a There are FOUR listview consumers now, not three + +§14 measured "exactly **three** `pmacs.listview.open` call sites, all +three in `builtin/runtime/lsp.lua`" at `ad41cf1`. **There are four**, and +the fourth matters here: + +| call site | panel | `on_visit` | `on_refresh` | `g` bound? | refresh advertised? | refresh FUNCTIONAL? | +|---|---|---|---|---|---|---| +| `lsp.lua:2442` | `*references*` | yes | no | **yes** | no | **no** | +| `lsp.lua:2488` | `*outline*` | yes | no | **yes** | no | **no** | +| `lsp.lua:2924` | `*lsp-help*` | no | no | **yes** | no | **no** | +| `lsp.lua:3004` | `*lsp*` (`lsp.status`) | no | **yes** | **yes** | **yes** | **yes** | + +**`g` is bound on ALL FOUR.** `bind_local_keymap` binds +`g → listview.refresh` for every panel unconditionally +(`listview.lua:147`), so "no `g`" — which revisions 2 and 3 both said — +is **literally false**. What three of them lack is an `on_refresh`, and +`listview.refresh` returns immediately without one. + +**So `g` on the outline is a DEAD BINDING**: bound, dispatched, +silently does nothing. That is a small UX wart in its own right — a key +that responds to nothing, with no status message — and it is a separate +observation from anything this framing proposes. Recorded, not fixed +here. + +`*lsp*` arrived with Journey Stage 1b-2 (#204), after §14's audit. It is +**the only listview consumer with refresh at all**, which is why §1.5a's +scoping conclusion holds: refresh is a feature exactly one panel has, and +it is not the anchor. + +§14's line numbers have also drifted (`:2056`/`:2102`/`:2513` against +today's `:2442`/`:2488`/`:2924`). The count is the part that matters; +this is recorded so the next reader does not inherit "three". + +### 1.4 What listview's model would have to gain + +listview's contract today: + +```lua +pmacs.listview.open { + name = "*references*", + header = "12 references RET visit n/p move g refresh q quit", + rows = { { text = "src/foo.rs:12:4", item = }, ... }, + on_visit = function(item) ... end, + on_refresh = function() return rows end, +} +``` + +and `render` is: + +```lua +local lines = { p.header } +p.line_to_item = {} +for _, row in ipairs(rows) do + lines[#lines + 1] = row.text + p.line_to_item[#lines - 1] = row.item +end +pmacs.buffer.set_generated_contents(p.buffer, table.concat(lines, "\n")) +``` + +**A flat array plus a line→item map.** Depth appears nowhere; it is +baked into `row.text` before listview ever sees it. `item` is `` — +**opaque to listview by design**, which matters for §2's identity +question. + +### 1.5 Refresh restores a LINE, not a node — and that is the crux + +```lua +local saved = pmacs.editor.cursor_line() +local rows = p.on_refresh() or {} +render(p, rows) +... +seat_cursor(p, saved) +``` + +`refresh` saves a **line number**, rebuilds the rows wholesale from a +freshly-produced array, and re-seats by walking `move_down` that many +times. + +Today this is a mild wrong-restore: if the new list has a different +shape, the cursor lands on whatever row now occupies that line. For a +flat list of roughly stable shape, tolerable. + +**Collapse breaks it outright.** Expanding a node inserts rows *above* +the cursor, so a line-keyed restore lands somewhere unrelated. And +collapse state itself must survive refresh, which requires recognising +"the same node" across two independently-produced arrays — something +neither `line_to_item` nor an opaque `item` can do. + +**This is why Q#TR3 exists and why it is not a detail.** It decides +whether selection and expansion can survive a model update at all. + +### 1.5a …but the ANCHOR CONSUMER HAS NO REFRESH AT ALL + +Revision 1 built two acceptance criteria on `g` refresh without checking +that the outline supports it. **It does not:** + +- its header offers `RET visit n/p move q quit` — refresh is **not + advertised** (`lsp.lua:2490`); +- it supplies **no `on_refresh`**; +- `listview.refresh` opens `if not (p and p.on_refresh) then return + end` — **a no-op** for this panel (`listview.lua:262`); +- and `g` **is** bound regardless (§1.3a), so the outline has a + **dead refresh binding**, not an absent one. + +So "collapse state survives `g`" was unreachable for the only consumer +that exists. **Refresh is therefore out of scope for this stage** unless +someone first answers a question this framing does not: an outline +refresh means **re-requesting `textDocument/documentSymbol`**, which is +an async LSP round-trip with its own await, failure and staleness +handling — and it raises who owns the resulting state when the response +arrives against a buffer the user may have edited or left. + +That is a real feature — **`*lsp*` (`lsp.status`) has `g refresh` and an +`on_refresh`** (`lsp.lua:3004`); the outline never gained one — and it is +**not** a tree concern. Bundling it here would make the tree lane +responsible for LSP request lifecycle. + +*(Revision 2 attributed refresh to `*references*` twice. It has neither: +its header is `RET visit n/p move q quit` and it supplies only +`on_visit` (`lsp.lua:2442`). The error came from reading +`listview.lua`'s **module-docstring example**, which illustrates the API +using `name = "*references*"` and a header containing `g refresh` — an +example, not a consumer.)* + +**Consequence for acceptance:** the criteria are re-scoped to what the +anchor consumer can actually exercise — collapse and selection surviving +**re-render**, which the primitive controls — and refresh-survival is +recorded as a follow-on for whoever gives the outline a refresh. + +### 1.6 What is NOT established + +- **Nothing is implemented or measured.** Unlike the last two lanes + there is no fallout to census: §14 grades Tree ✗, so there is no + existing behaviour to preserve and no baseline to diff. **This framing + argues a model rather than measuring one**, which is the shape that + has historically needed the most review rounds here (Lean Stage 3b + took six; the signal lane had three tolerance rules rejected in a + row). Treat its claims as proposals. +- **No consumer has asked for collapse.** The outline's limitation is + inferred from its structure and its own "indent, don't recurse" + comment, not from a user report. +- **dired's `i` has not been re-scouted** against current `main`; §13's + deferral is the only evidence that its requirements are as described. + +--- + +## 2. Questions + +All four are genuinely open. The first two the review already flagged as +open; the third is the one review added; the fourth follows from §1.4. + +- **Q#TR1 — extend `listview`, or add a separate `treeview`?** + + **Revision 1 framed this wrongly and the correction changes the + tradeoff.** It claimed a separate treeview would be "exactly the + second primitive §14 warns about". §14 does not warn against a tree — + **it explicitly lists one**, alongside virtual list, in the reusable + set it wants: *"editable text view, virtual list, **tree**, structured + table, inspector…"* (`COHERENCE.md:1264`). A tree surface is a named + goal, not a violation. + + What §14 actually warns against is **bespoke per-consumer plumbing** — + each subsystem inventing its own UI vocabulary. A `treeview` that + shares the existing buffer/panel disciplines (generated-buffer writes, + Q#GB18 handle identity, panel placement, `q` quit-action) is not that; + a tree hand-rolled inside `lsp.lua` would be. + + So the real tradeoff is narrower: + - **Extending listview** touches three shipped call sites and the + `line_to_item` contract, and risks making a working flat primitive + worse for the consumers that do not need depth. + - **A separate treeview** keeps the flat primitive untouched, but must + *share* rather than *duplicate* the panel disciplines — and "shares + them" is an implementation claim that has to be verified, not + asserted. + + **No leaning recorded**; the scout found nothing that decides it. +- **Q#TR2 — who owns collapse state?** Candidates: the primitive (keyed + by node id), the consumer (passed in with the rows each render), or + the buffer (as generated-buffer state). Consumer-owned keeps the + primitive stateless and makes refresh the consumer's problem; + primitive-owned centralises it and forces Q#TR3 to be answered first. +- **Q#TR3 — what is a stable node identity across refresh?** *(Added at + review.)* This determines whether **selection and expansion survive a + model update** (§1.5). Constraints the scout established: + - listview cannot derive one: `item` is `` and opaque. + - The outline's data is nearly sufficient — the `::`-joined parent + chain plus name — but **not unique**: overloads and same-named + siblings collide. + - dired's would be genuinely stable: the path. + - So identity is almost certainly **consumer-supplied**, which makes + it part of the primitive's public contract rather than an internal + detail. That is a real API commitment and should be decided + deliberately, not defaulted into. +- **Q#TR4 — does the row still carry pre-rendered `text`?** Today the + consumer formats indentation into the string. If the primitive owns + depth it should probably own indentation too — but the outline also + appends `[kind]` tags and dired has a fixed-width column contract, so + "the primitive renders the row" may not survive contact with either. + +--- + +## 3. Bets + +- **Bet 1 — the outline is a sufficient first consumer on its own.** Its + data already carries depth and parent; adopting it requires no LSP-side + change. *Falsified if adoption needs `Symbol` to change shape.* +- **Bet 2 — identity must be consumer-supplied** (§1.5, Q#TR3). + *Falsified if some derivable key proves both stable and unique across + the two consumers.* +- **Bet 3 — no interaction island is required.** Expand/collapse are + buffer-local bindings on a generated buffer, exactly as `RET`/`n`/`p`/ + `g`/`q` already are. *Falsified if some behaviour cannot be expressed + that way — which would be a finding worth reporting, not a licence.* + +--- + +## 4. Acceptance + +**Not final** — this framing argues a model, and the criteria cannot be +fixed until Q#TR1–TR3 are decided. The shapes they will take: + +1. The **LSP outline supplies structural `depth` and `id`** so the + primitive can collapse and expand, and `Symbol` is unchanged. + **Per Q#TR4 its `text` stays consumer-rendered** — the `string.rep` + indentation remains in `lsp.lua`, because collapse hides rows without + changing any surviving row's depth, so pre-rendered indentation is + still correct. `id` is `line:col`. +2. **Collapse and expand work**, and **collapse state survives a + re-render** — the primitive re-emitting the buffer from the same + model. *(Not "survives `g` refresh": the anchor consumer has no + refresh at all — §1.5a.)* +3. **Selection survives a re-render by node, not by line** (§1.5). +4. **No new interaction island**: every tree key is a buffer-local + binding, and the dispatch-shadow count is unchanged. Asserted, not + assumed. +5. **listview's OTHER consumers are unaffected** — `*references*`, + `*lsp-help*` and `*lsp*` (§1.3a: four call sites, all in `lsp.lua`). + + **What existing suites actually pin, stated honestly.** + `listview_acceptance` drives the substrate hermetically and says so + in its own header: *"The references panel itself needs a live LSP and + is validated manually / via the m4 harness"* — so **it does not + exercise `*references*` at all**. The m4 hover test asserts content + *presence*, not byte-exact output. **"Byte-identical, pinned by + existing suites" was therefore unsupported** for both panels named. + + So this criterion needs a decision, not a wording tweak: + - **either** byte-identity becomes a **new test requirement** this + stage writes — capturing each panel's rendered buffer before and + after and diffing it, which needs the m4 fake-LSP harness for + `*references*`; + - **or** the claim weakens to what is genuinely pinned today: + the substrate behaviours `listview_acceptance` covers (open, + navigate, visit, `q` restore, the read-only intercept, the + round-trip gate, refresh) plus content-presence for hover. + + **DECIDED: write the byte-identity test**, including the fake-LSP + harness work for `*references*`. A flat consumer silently gaining an + indent column is exactly the regression this criterion exists to + catch, and content-presence would not see it. + + **Revision 1 named `*buffer-list*` and project search here and was + wrong** — §14 measured that they do **not** use listview and calls + the earlier claim a documentation error (`COHERENCE.md:1286`). + Repeating it would have re-introduced a mistake that document exists + to correct. If the broader surfaces are ever in scope, their + independent render paths and tests must be named explicitly. +6. **Q#GB18 identity holds**: a foreign buffer with the panel's name is + never adopted, and the primitive is keyed by handle, not name. +7. The **generated-buffer write invariant** is preserved + (`set_generated_contents`), including the rope lock and history rules + the listview suite already pins. + +--- + +## 5. Parked + +- **The other four §14 consumers** — project files, package dependency + graph, worker trees, git status. They adopt later; designing for them + now is the failure this framing avoids. +- **DAP's variables view.** The reason the primitive is worth building + before the debugger, and not part of it. +- **dired's `i` insert-subdirectory.** The second consumer, and the + right forcing function for the design — but it is its own stage with + its own framing, and dired Stage 2b/3 are ahead of it. +- **Making `*workers*` a listview/tree consumer** (§1.3), and the + raw-switch hazard noted there. +- **Tree rendering in the GPU frontend** beyond whatever the shared + generated-buffer path already gives. +- **Giving the outline a refresh** (§1.5a). **`*lsp*` has `g` and an + `on_refresh`**; the outline never gained one. Adding it means + re-requesting `textDocument/documentSymbol` with its own await, + failure and staleness handling, and deciding who owns the result when + it arrives against a buffer the user may have edited. **That is LSP + request-lifecycle work, not tree work** — and only once it exists can + "collapse survives refresh" be a criterion rather than an aspiration. + +--- + +## 6. Gates + +The standing `CLAUDE.md` suite. The suites most likely to move are +`listview_acceptance` and `m4_acceptance` (the LSP outline and hover +panels are listview consumers — bottom-panel Stage 3 established that +transitive relationship the hard way). + +**Sweep both feature configurations.** Stage 3 shipped a broken +crdt-gated suite because every local sweep ran `--features luajit` +without `crdt`; and `--no-fail-fast` is required, or a multi-suite break +reports as one suite. + +--- + +## 6a. Verification record, including one unclassified occurrence + +**The luajit sweep is 3453 / 0** and the count reconciles exactly: +`main` is 3450 (Stage 3's 3449 sweep predated its own capability-fallback +pin) plus this lane's three listview tests and one m4 test. + +**The crdt sweep is 3722 / 0**, likewise +4 on `main`'s 3718. + +### An UNCLASSIFIED, UNCAPTURED local occurrence + +The **first** local crdt sweep of this branch reported **7 failures**. +**It is recorded here as unclassified and it is deliberately NOT a row +in `docs/ci-red-signatures.md`** — that registry keys on a normalized +signature, and this occurrence has none to match, so a row would confer +recognisability it cannot support. + +**The signatures were destroyed before they were read.** The sweep was +piped through an aggregation that emitted only totals. That is the exact +failure the registry exists to prevent, committed one lane after writing +it — and it is why the cause cannot now be established rather than +merely being unknown. + +**Re-runs, with what each does and does not support:** + +| run | isolated? | result | +|---|---|---| +| first | no — concurrent with another lane's build | **7 failed, signatures lost** | +| second | no | 3722 / 0 | +| A | **no** — the isolation guard printed "aborting" and did not abort | 3722 / 0 | +| B | **yes** — verified idle | 3722 / 0 | +| C | **yes** — verified idle | 3722 / 0 | + +Two genuinely isolated runs, both clean. **That supports repeatability +under isolation. It does not establish what caused the original.** + +### Two NON-CAUSAL hypotheses, neither testable now + +Both are mechanisms known to have been present. Neither is offered as an +explanation, because the occurrence's signatures no longer exist to test +either against: + +1. **Shared `CARGO_TARGET_DIR`.** Another lane's worktree shared + `/home/jeans/build/cargo-target`, so its `cargo test --workspace` + overwrote `target/debug/pmacs` mid-sweep. That lane observed the + reciprocal case independently, caught the concurrent build with + `pgrep`, and its failing text named its own cause ("start the daemon + built with the `crdt` feature"). +2. **Resident leaked daemons.** ~40 orphaned `pmacs --daemon` processes + were present, some four days old (see the lane in + `docs/active-work.md`). Isolated sweeps leak 3–4 each, so the + population was growing throughout. + +**Having two plausible mechanisms and no way to discriminate is the +result.** Reporting either as *the* cause would be the reasoning this +project has rejected repeatedly: concluding something about an +occurrence from something that was not about that occurrence. + +## 7. Branch plan + +Q#TR1 is decided, so the listview-extension shape applies: + +1. **Extend `listview`** — optional `depth` / `id` on rows, + primitive-owned collapse state, ancestor-collapsed filtering in + `render`, selection re-seated **by id**, and a toggle binding. Absent + `depth`/`id` must behave exactly as today. +2. **Byte-identity proof for the flat consumers** (acceptance 5), + landing *before* the outline adopts, so any regression in + `*references*`, `*lsp-help*` or `*lsp*` is attributable to the + primitive change rather than to adoption. +3. **The outline adopts** — supplies `depth` and `id = line:col`, keeps + its rendered `text`. +4. **Lane, handoff and `COHERENCE.md` §14** updated; the §14 correction + from revision 4 rides this PR per §25. + +dired's `i` is **not** attempted here (§5). diff --git a/tests/listview_acceptance.rs b/tests/listview_acceptance.rs index e316e41..e845fce 100644 --- a/tests/listview_acceptance.rs +++ b/tests/listview_acceptance.rs @@ -739,6 +739,201 @@ fn s1_11_a_disambiguated_panel_still_answers_ret_g_and_q() { /// raw-switch and capability-fallback listview loops**. `s1_12` pins the /// second by keeping its panels in document windows; this pins the /// first. +/// Tree primitive — a panel with `depth` + `id` collapses and expands, +/// and BOTH the collapse state and the selection survive a re-render. +/// +/// Acceptance 2 and 3. The re-render is what the primitive controls; +/// `g` refresh is deliberately out of scope because the anchor consumer +/// (the outline) has no `on_refresh` at all — see the framing's §1.5a. +#[test] +fn tr_1_collapse_hides_descendants_and_survives_re_render() { + let mut s = editor(); + exec( + &s, + r#"pmacs.listview.open { + name = "*tree*", + header = "tree TAB fold", + rows = { + { text = "root", item = "root", depth = 0, id = "a" }, + { text = " kid1", item = "kid1", depth = 1, id = "b" }, + { text = " kid2", item = "kid2", depth = 1, id = "c" }, + { text = "tail", item = "tail", depth = 0, id = "d" }, + }, + }"#, + ); + let body = |s: &EditorState| active_text(s); + assert!( + body(&s).contains("kid1"), + "children visible before collapse" + ); + + // Cursor opens on the first data row (root); TAB collapses it. + press(&mut s, KeyCode::Tab); + let collapsed = body(&s); + assert!( + !collapsed.contains("kid1"), + "descendants hidden: {collapsed}" + ); + assert!( + !collapsed.contains("kid2"), + "ALL descendants hidden: {collapsed}" + ); + assert!( + collapsed.contains("root") && collapsed.contains("tail"), + "the node itself and its SIBLING survive — collapse hides \ + descendants, not the following run: {collapsed}" + ); + + // Selection is re-seated by ID, so the cursor is still on `root`. + let on_root: String = eval( + &s, + "return pmacs.describe.buffer(pmacs.window.buffer()).name", + ); + assert_eq!(on_root, "*tree*"); + + press(&mut s, KeyCode::Tab); + assert!( + body(&s).contains("kid1") && body(&s).contains("kid2"), + "TAB again expands" + ); +} + +/// Selection survives a re-render that MOVES the selected node. +/// +/// `tr_1` is not sufficient for this and was vacuous as a selection +/// test: it toggles the ROOT, which occupies line 1 before and after the +/// collapse, so the old line-based re-seating would pass it unchanged. +/// A selection test has to move the node. +/// +/// Here `on_refresh` inserts a child ABOVE the selected sibling, so the +/// sibling's line shifts. Re-seating by line would land on the inserted +/// row; re-seating by id stays on the sibling. +#[test] +fn tr_4_selection_follows_the_node_when_rows_are_inserted_above_it() { + let mut s = editor(); + exec( + &s, + r#"_G.EXTRA = false + pmacs.listview.open { + name = "*tree*", header = "tree", + rows = { + { text = "root", item = "root", depth = 0, id = "a" }, + { text = " kid", item = "kid", depth = 1, id = "b" }, + { text = "sibling", item = "sibling", depth = 0, id = "z" }, + }, + on_refresh = function() + if _G.EXTRA then + return { + { text = "root", item = "root", depth = 0, id = "a" }, + { text = " kid", item = "kid", depth = 1, id = "b" }, + { text = " kid2", item = "kid2", depth = 1, id = "c" }, + { text = "sibling", item = "sib", depth = 0, id = "z" }, + } + end + return { + { text = "root", item = "root", depth = 0, id = "a" }, + { text = " kid", item = "kid", depth = 1, id = "b" }, + { text = "sibling", item = "sib", depth = 0, id = "z" }, + } + end, + }"#, + ); + + // Select `sibling` — data line 3. + press(&mut s, KeyCode::Char('n')); + press(&mut s, KeyCode::Char('n')); + let line_before: i64 = eval(&s, "return pmacs.editor.cursor_line()"); + let text_at = |s: &EditorState| -> String { + let body = active_text(s); + let line: i64 = eval(s, "return pmacs.editor.cursor_line()"); + body.lines() + .nth(usize::try_from(line).expect("line fits")) + .unwrap_or_default() + .to_string() + }; + assert_eq!(text_at(&s), "sibling", "premise: sibling is selected"); + + // Refresh inserts `kid2` ABOVE sibling, so its line moves. + exec(&s, "_G.EXTRA = true"); + press(&mut s, KeyCode::Char('g')); + + let line_after: i64 = eval(&s, "return pmacs.editor.cursor_line()"); + // Substantive claim first, so a regression reports as what it is. + // Under line-based re-seating the cursor stays on line 3, which now + // holds the INSERTED row. + assert_eq!( + text_at(&s), + "sibling", + "selection follows the NODE, not the line" + ); + // …and the fixture really did move it, so the assertion above is not + // satisfied by the node happening to stay put (which is exactly how + // `tr_1` is vacuous as a selection test). + assert_ne!( + line_before, line_after, + "fixture: the insert must move the selected node" + ); +} + +/// A leaf reports rather than silently doing nothing. +/// +/// The outline's `g` is already a dead binding — bound, dispatched, no +/// feedback (framing §1.3a). This primitive must not add a second one. +#[test] +fn tr_2_toggling_a_leaf_reports_instead_of_silently_doing_nothing() { + let mut s = editor(); + exec( + &s, + r#"pmacs.listview.open { + name = "*tree*", header = "tree", + rows = { { text = "leaf", item = "leaf", depth = 0, id = "only" } }, + }"#, + ); + press(&mut s, KeyCode::Tab); + assert!( + status(&s).contains("no children"), + "a leaf toggle says so; got: {}", + status(&s) + ); +} + +/// Rows WITHOUT `depth`/`id` behave exactly as before — the property +/// that keeps the three flat consumers unaffected (acceptance 5). +#[test] +fn tr_3_a_flat_panel_is_untouched_by_the_tree_extension() { + let mut s = editor(); + exec( + &s, + r#"pmacs.listview.open { + name = "*flat*", header = "flat", + rows = { { text = "one", item = 1 }, { text = "two", item = 2 } }, + }"#, + ); + let before = active_text(&s); + let status_before = status(&s); + press(&mut s, KeyCode::Tab); + assert_eq!( + active_text(&s), + before, + "TAB on a depthless panel changes nothing" + ); + // Byte-identity of the BUFFER is not enough: TAB is bound for every + // listview, so the tree command intercepts a key that previously + // fell through to `buffer.tab` and the read-only intercept. A + // listview-specific status here would be a behaviour change the + // flat consumers never had, and invisible to a buffer comparison. + assert!( + !status(&s).contains("no node here") && !status(&s).contains("no children"), + "a flat panel must not gain tree feedback; status was {:?} (was {:?})", + status(&s), + status_before + ); + assert!( + before.contains("one") && before.contains("two"), + "both rows render: {before}" + ); +} + #[test] fn s3_1_q_walks_the_side_presentation_chain_back_to_the_document() { let mut s = editor(); @@ -880,6 +1075,127 @@ fn s1_14_no_bypass_write_or_name_keyed_identity_remains() { ); } +/// A tree row need not carry `item` — `on_visit` is optional, so a +/// display-only node (a grouping header) is a legitimate row. The +/// cursor must still seat on it. +/// +/// This bit: `line_to_item` is SPARSE when rows omit `item`, and +/// `seat_cursor` took `#` of it. For an all-display-only tree that +/// length is 0, so the cursor never left the header — where TAB finds +/// no row and answers "no node here", making the tree unfoldable. +#[test] +fn tr_5_a_tree_of_display_only_rows_is_still_navigable_and_foldable() { + let mut s = editor(); + exec( + &s, + r#"pmacs.listview.open { + name = "*tr5*", + header = "display-only TAB fold", + rows = { + { text = "root", depth = 0, id = "r" }, + { text = " kid", depth = 1, id = "rk" }, + }, + }"#, + ); + + // Seated on a data row, not stranded on the header. + let line: i64 = eval(&s, "return pmacs.editor.cursor_line()"); + assert_eq!( + line, 1, + "cursor seats on the first data row despite no `item`" + ); + + press(&mut s, KeyCode::Tab); + assert!( + !status(&s).contains("no node here"), + "TAB found the node: {}", + status(&s) + ); + assert!( + !active_text(&s).contains("kid"), + "and folded it: {:?}", + active_text(&s) + ); +} + +/// Q#TR3's contract is that ids compare by value. Collapse state keys +/// a Lua table, and table indexing consults no `__eq`, so a non-scalar +/// id would compare equal for selection and unequal for folding: a +/// refresh would restore the cursor and silently lose the fold. The +/// contract is narrowed to scalars and enforced where rows enter, +/// rather than left to surface as a lost fold much later. +#[test] +fn tr_6_a_non_scalar_id_is_rejected_where_rows_enter() { + let s = editor(); + let err: String = eval( + &s, + r#"local ok, e = pcall(function() + pmacs.listview.open { + name = "*tr6*", + header = "h", + rows = { { text = "a", depth = 0, id = {} } }, + } + end) + return tostring(e)"#, + ); + assert!( + err.contains("ids must be a string or number"), + "rejected where rows enter, with a reason: {err}" + ); +} + +/// A NaN id passes `type(x) == "number"` and then errors at +/// `p.collapsed[row.id]` with "table index is NaN" — the one scalar +/// Lua accepts as a number and refuses as a table key. It must be +/// caught where rows enter, naming the row, rather than surfacing on +/// whichever later TAB happens to reach it. +#[test] +fn tr_7_a_nan_id_is_rejected_rather_than_erroring_on_the_first_fold() { + let s = editor(); + let err: String = eval( + &s, + r#"local ok, e = pcall(function() + pmacs.listview.open { + name = "*tr7*", + header = "h", + rows = { { text = "a", depth = 0, id = 0 / 0 } }, + } + end) + return tostring(e)"#, + ); + assert!( + err.contains("NaN id"), + "named at entry, not as a table-index error later: {err}" + ); +} + +/// Duplicate ids do not merely collide — every lookup resolves an id to +/// the FIRST row bearing it, so selecting the second toggles the first +/// and re-seats the cursor onto it. An id that does not identify a node +/// is not an id. +#[test] +fn tr_8_duplicate_ids_are_rejected_because_lookup_takes_the_first_match() { + let s = editor(); + let err: String = eval( + &s, + r#"local ok, e = pcall(function() + pmacs.listview.open { + name = "*tr8*", + header = "h", + rows = { + { text = "first", depth = 0, id = "same" }, + { text = "second", depth = 0, id = "same" }, + }, + } + end) + return tostring(e)"#, + ); + assert!( + err.contains("share the id") && err.contains("rows 1 and 2"), + "both offending rows named: {err}" + ); +} + // Isolated bootstrap storage roots (see the module docs): an // integration test is compiled without `cfg(test)`, so a raw // `EditorState::new()` would read the developer's real `init.lua` and diff --git a/tests/m4_acceptance.rs b/tests/m4_acceptance.rs index 68b1825..bf6b7f2 100644 --- a/tests/m4_acceptance.rs +++ b/tests/m4_acceptance.rs @@ -8025,6 +8025,99 @@ fn open_against_fake(path: &std::path::Path) -> pmacs::editor::EditorState { /// hierarchical documentSymbol response ("Outer" class > "inner" /// method): open, depth-indented rows, RET jump-ring visit to the /// symbol's selectionRange, M-, back to the outline row, q restore. +/// Tree primitive, acceptance 5 — the FLAT listview consumers render +/// **byte-identically** after the depth/collapse extension. +/// +/// This exists because the weaker claim was not true. `listview_ +/// acceptance` says in its own header that the references panel "needs +/// a live LSP and is validated manually / via the m4 harness", so it +/// does not exercise `*references*` at all; and the hover test asserts +/// content *presence*, not exact output. Neither would notice a flat +/// consumer silently gaining an indent column — which is precisely the +/// regression a tree extension can introduce. +/// +/// So the assertion is on the **exact rendered bytes**, through the +/// real entry points, against the fake language server. +#[test] +fn flat_listview_consumers_render_byte_identically_after_the_tree_extension() { + let dir = tempfile::tempdir().expect("tempdir"); + let a_path = dir.path().join("r.rs"); + std::fs::write(&a_path, b"fn main() {}\n").expect("write r"); + let mut state = open_against_fake(&a_path); + + let body = |state: &pmacs::editor::EditorState| -> String { + state + .lua_host + .lua() + .load("local b = pmacs.window.buffer() return b:slice(0, b:len())") + .eval() + .expect("panel text") + }; + + // --- *references* (on_visit, no depth) --- + state + .lua_host + .lua() + .load("pmacs.lsp.find_references()") + .exec() + .expect("invoke find_references"); + assert!( + pump_lua_flag( + &mut state, + "pmacs.describe.buffer(pmacs.window.buffer()).name == '*references*'", + 5, + ), + "the references panel opened" + ); + let refs = body(&state); + let (header, rows) = refs.split_once('\n').expect("header then rows"); + assert_eq!( + header, "1 reference RET visit n/p move q quit", + "the header is unchanged — no fold affordance is advertised on a \ + flat panel" + ); + // EXACT: the row is the location string and nothing else. An added + // indent column, tree gutter or fold marker would all fail here. + assert_eq!( + rows, + format!("{}:12:3", a_path.display()), + "the flat references row renders verbatim" + ); + + // --- *lsp* (on_refresh, no depth) --- + state + .lua_host + .lua() + .load("pmacs.command.invoke('lsp.status')") + .exec() + .expect("invoke lsp.status"); + let status_body = body(&state); + let (status_header, status_rows) = status_body.split_once('\n').expect("header then rows"); + assert_eq!( + status_header, "LSP status g refresh q quit", + "the one panel WITH refresh keeps its exact header" + ); + // `*lsp*` formats its OWN indentation — two spaces on detail lines — + // so "starts with a space" is not a violation here. What must hold + // is that the primitive reproduces the consumer's text EXACTLY: a + // prefix added by render would shift this line and break the match. + // + // Matched as a whole line rather than a substring, because a + // substring would still be found inside a further-indented version + // of itself. Volatile parts (pid, elapsed) are deliberately not + // included. + assert!( + status_rows + .lines() + .any(|l| l == " capabilities: sync, hover, completion, definition, diagnostics"), + "the consumer's own two-space indentation survives verbatim; got:\n{status_rows}" + ); + assert!( + status_rows.lines().any(|l| l == "Servers:"), + "an unindented row stays unindented; got:\n{status_rows}" + ); +} + #[test] #[allow( clippy::too_many_lines,