merge: integrate main @ 5376af1; move the §18 and scorecard grades

The journey suite conflicted additively — step 4 from this lane, step 6
from #204 — and both are kept: 44 pins now cover steps 2, 3, 4, 5, 6
and 9.

Per §25 the audited claims this stage falsifies are updated on the
landing PR rather than deferred: the scorecard's row 18 and §18's
ground truth both read "Missing" / "missing entirely", and a welcome
buffer plus a reachable cheat sheet makes both false. They move to
Partial. §2's step-4 row stays Partial, because `C-h` still deletes a
word and there is no tutorial.

§18's ground truth now records WHY `C-h` stays as it is, so the
help-prefix question reaches the discovery arc as a stated trade rather
than an oversight: non-kitty terminals cannot disambiguate
Ctrl+Backspace from Ctrl+H, so rebinding it would break Ctrl+Backspace
on every legacy terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
Levi Neuwirth 2026-07-31 16:01:29 -04:00
commit 35cc9ff0c5
8 changed files with 1957 additions and 162 deletions

View File

@ -111,7 +111,7 @@ remain open to them.
| 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 | **Missing** | CI is test-only; no binaries, channels, checksums, or update path |
| 18 | Onboarding | **Missing** | No welcome, no tutorial; `C-h` deletes a word; `M-x` is the only door in |
| 18 | Onboarding | **Partial** | Journey Stage 1b-3: an unconfigured launch greets in `*scratch*` naming `M-x` and four real bindings, and `M-x help` renders a cheat sheet. Still no tutorial and `C-h` still deletes a word — deliberately, see §18 |
| 19 | Coherence acceptance tests | **Started** | `tests/journey_acceptance.rs` exists (steps 2, 3, 5); the other five scenarios are still unwritten |
Three cross-cutting patterns explain most of the table; they are
@ -219,16 +219,31 @@ Automatic, background failures are swallowed. The canonical case, hit on
**every file open** when a language server is preconfigured but not
installed: `Command::spawn` ENOENT propagates up through
`LspManager::spawn` and raises in Lua — where `ensure_server` `pcall`s
it and returns nil (`builtin/runtime/lsp.lua:614-626`), and the
`buffer.after-load` hook `pcall`s the whole attach
(`builtin/runtime/lsp.lua:895-897`). Net user-visible result: nothing.
No status message, no `*errors*` entry, no modeline marker (the LSP
segment is gated on an attachment record existing, so absence is
indistinguishable from "unsupported file type"). Working tree-sitter
highlighting **actively masks** the failure — the user sees colored text
and assumes language intelligence is on. Post-crash is the same shape:
`LspEventKind::Crashed` is pushed (`src/lsp.rs:2394`) and no builtin
subscriber surfaces it.
it, and the `buffer.after-load` hook `pcall`s the whole attach. Net
user-visible result used to be nothing: no status message, no `*errors*`
entry, no modeline marker (the LSP segment is gated on an attachment
record existing, so absence was indistinguishable from "unsupported file
type"). Working tree-sitter highlighting **actively masks** the failure —
the user sees colored text and assumes language intelligence is on.
**Journey Stage 1b-2 answers this specific case**
(`docs/journey-stage1b2-lsp-guidance-framing.md`): the failure is
reported once per `(language, root, command)` with the command, the
language and the errno; `M-x lsp.status` renders a durable `*lsp*` panel;
and the modeline says `LSP:!` instead of nothing. **The asymmetry itself
is not retired** — the rule below still needs adopting site by site, and
`pmacs.error` is still undefined.
Post-crash is the same shape and is **not** covered:
`LspEventKind::Crashed` is pushed and no builtin subscriber surfaces it.
A server that started and then died is a different failure with a
different message.
*(Citation note: this paragraph carried three stale line references —
`ensure_server` was cited at `:614-626` when the spawn `pcall` is at
`:658-674`, and the `buffer.after-load` hook at `:895-897` when it is at
`:1019-1021`. Symbols are authoritative per §25; the numbers are dropped
rather than re-pinned.)*
This directly contradicts the product thesis (§23): the "without
freezing" half is delivered; the "without becoming opaque" half is
@ -258,10 +273,14 @@ wiring must log attributed failure, never discard it. Corollary from the
above: report through a channel with a **test that observes it**, or the
guard is indistinguishable from the silence it was meant to fix.
**Frequency note (PR #161):** per-root server affinity means the
preconfigured-but-missing-server failure now fires **once per project
root** rather than once per language per session. The silence is
unchanged in kind; it is strictly more frequent. Surfacing it stays
**Frequency note — corrected by Stage 1b-2.** This previously said the
failure fires "once per project root". It did not: `LspManager::spawn`
returns early *before* both `status_tracker.ensure` and
`clients.insert`, so a failed spawn left **no record at all**,
`pmacs.lsp.list()` could not see it, and `ensure_server`'s affinity loop
re-spawned. The real rate was **once per file open** — strictly worse
than recorded, and the reason the fix memoizes the *report* while still
retrying the spawn. Surfacing it stays
Priority 1 work with its own framing — it is a user-visible product
behavior (what message, where, with what guidance), not a substrate fix
to smuggle into an affinity PR.
@ -373,12 +392,12 @@ Full verdict table:
| 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime |
| 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on |
| 3 | Open real project | **Works at the CLI** | Journey Stage 1a: `resolve_target_buffer` answers `ResolvedTarget::Directory` before the EISDIR-producing load, and `EditorState::open` / the daemon bootstrap dispatch the `path.open-directory` chain, whose fallback is dired (#165's buffer, reached rather than duplicated). Startup no longer fails: an unreadable directory, a crashed resolver, and a cleared handler all report on the status line and leave the session running. Because the listing is async and the bootstrap is synchronous, the commit runs against a destination captured at request time (`pmacs.window.commit_to`) rather than against the ambient frontend |
| 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) |
| 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments. Journey Stage 1b-3 adds a welcome in `*scratch*` and `M-x help`; **still Partial** because `C-h` deletes a word (deliberately — §18) and there is no tutorial |
| 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config |
| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose |
| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua`). **Journey Stage 1b-2 (PR open) ends the silence** for a server that fails to *start*: the status line names the command, language and errno once per `(language, root, command)`; the modeline reads `LSP:!` instead of nothing; and `M-x lsp.status` renders `*lsp*` over the `status_buffer_text()` renderer that had existed since M4.8 with no caller. **Still Partial**, and flips only on merge (§25): a server that starts and then *crashes* is still unsurfaced |
| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI |
| 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173), and searchable through `M-x terminal.copy-mode` / `C-c C-t`, which materializes the retained scrollback into an ordinary read-only buffer (Stage 2). Named limitations: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there; and there is still **no close/kill command**, which is the remaining half of this step's discoverability gap. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
| 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to the detected project root, and parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua`), and no `cargo build` suggestion despite `ProjectKind::Rust` existing (`src/project.rs:78` — **not** `Cargo`, see §24). **PR #203 (open) closes all three**: `C-c c` runs `compile.run`, and the first prompt is prefilled from the detected project kind (`pmacs.compile.defaults`, seeded `rust = "cargo build"`). The prompt **captures** its directory rather than re-resolving at accept time, so the command it offers and the directory it runs in cannot drift while the minibuffer waits. Named limitation it does not fix: after `pmacs <dir>` the active buffer is dired's and pathless, so the cwd falls back to the process cwd — §8's execution-location model owns that. **This row flips to Works when #203 merges**; per §25 a grade changes only on landed evidence |
| 9 | Build / test | **Works** | Journey Stage 1b-1 (#203): `C-c c` runs `compile.run`, and the first prompt is prefilled from the detected project kind (`pmacs.compile.defaults`, seeded `rust = "cargo build"`, extensible from `init.lua`) via `ProjectKind::Rust`**not** `Cargo`, see §24. The prompt **captures** its directory rather than re-resolving at accept time, so the command it offers and the directory it runs in cannot drift while the minibuffer waits. Still defaults cwd to the detected project root and parses Rust `-->` errors. Named limitation: after `pmacs <dir>` the active buffer is dired's and pathless, so the cwd falls back to the process cwd — §8's execution-location model owns that, and the degradation stays coherent (no suggestion is offered for a directory with no detected Cargo project) |
| 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first |
| 11 | See background work | **Works but undiscoverable** | `*workers*` view via `M-x editor.list-workers`; `C-c C-k` cancel-at-point. No keybinding, no statusline spinner/progress indicator anywhere (§9) |
| 12 | Close + restore | **Partial** | Per-file cursor+scroll (saveplace), recent files, minibuffer history, autosave recovery all restore zero-config. Open-buffer set and window layout do **not**: desktop-save is opt-in (`pmacs.session.desktop_mode(true)`) *and* a documented no-op under a daemon (`src/desktop.rs:323-326`, `:353-356`, Q#DS9) |
@ -388,14 +407,15 @@ A journey observation worth keeping verbatim from the audit:
C-M-s` opens all folds, while opening a file, opening a terminal, and
running a build have no bindings at all.
Two of that observation's three examples have been answered — opening a
file by `C-x C-f` (#162) and opening a terminal by `C-c t` (#173).
**Running a build still has no binding on `main`**; PR #203 is open and
adds `C-c c`. **The quote stays as written either way**: it names a standing bias in how new work gets bound,
not three isolated omissions, and two fixes do not retire a bias.
**Running a build remains its uncontested golden-journey example until
#203 lands.** A new surface that ships without a binding would be further
evidence the pattern is live, and should be read that way.
All three of that observation's examples have now been answered —
opening a file by `C-x C-f` (#162), opening a terminal by `C-c t`
(#173), and running a build by `C-c c` (Journey Stage 1b-1, #203).
**The quote stays as written**: it names a standing bias in how new work
gets bound, not three isolated omissions, and three fixes do not retire
a bias. What has changed is that the bias no longer has an uncontested
example in the golden journey — a new surface that ships without a
binding would be evidence the pattern is live again, and should be read
that way.
---
@ -1462,16 +1482,26 @@ restartable help workspace, not a one-time modal wizard.
### Ground truth
**Grade: missing entirely.**
**Grade: partial — the cheap floor's first two items landed with Journey
Stage 1b-3.**
No welcome buffer, no tutorial, no first-run detection, no cheat sheet
reachable from inside the editor (`docs/keybindings.md` exists on disk
only). `C-h` is `buffer.delete-word-backward`; there is no help prefix
key and no `F1`. The sole discovery affordance is knowing to press
`M-x` (`builtin/keymaps/default.lua:141` — whose own header comment
calls it the "command palette"). The empty `*scratch*` buffer that
greets a new user says nothing (`EditorCore::new` sets an empty
status).
An unconfigured launch now greets in `*scratch*` naming `M-x` and four
real bindings, and `M-x help` renders a cheat sheet through the existing
`*help*` mechanism. The greeting happens in a launch-finalization seam
(`prepare_startup` → `EditorState::finalize_local_launch`) that runs
after config, after attach dispatch resolves to local, and after desktop
restore — no constructor is the right hook, because `EditorState::open`
calls `new` before resolving its target and the daemon constructs one
too.
Still missing: no tutorial, no first-run detection, and **`C-h` still
deletes a word — deliberately.** It is bound to
`buffer.delete-word-backward` because non-kitty terminals cannot
disambiguate Ctrl+Backspace from Ctrl+H (both produce byte 0x08,
`builtin/keymaps/default.lua:78-86`), so rebinding it to a help prefix
would break Ctrl+Backspace on every legacy terminal. The help-prefix
question is a real trade for §20 Priority 4's discovery arc to weigh
across the whole command family, not an oversight.
Note the dependency: five of the ten onboarding steps above currently
lead somewhere broken or invisible (find a file — the mechanism is fixed
@ -1480,7 +1510,9 @@ diagnostic — silent-failure risk; view workers — undiscoverable;
setting provenance — unanswerable). Onboarding is correctly sequenced
*after* the P1/P4 fixes, but the cheap floor — a welcome buffer in
`*scratch*` naming `M-x`, the keybinding cheat sheet as a help buffer,
and a help prefix decision — has no prerequisites at all.
and a help prefix decision — had no prerequisites at all. **The first
two are done** (Stage 1b-3); the third is deferred with its reason
recorded above.
---
@ -1543,19 +1575,19 @@ Establish the end-to-end workflow; treat regressions as release
blockers. **State: runs to step 5; thin from step 6 (§2). Mostly wiring,
and unusually cheap:** directory-argument handling (**done**: Journey
Stage 1a); a find-file surface (**done**: #162 open-by-path, #165
browsing); surfacing the LSP spawn failure with guidance (§1.2); a
compile keybinding + `cargo build`/`test` default (**in flight**:
Journey Stage 1b-1, PR #203, from the existing `ProjectKind::Rust`
**not** `Cargo`, see §24); a terminal keybinding (**done**: `C-c t`,
#173); a welcome buffer. The journey acceptance suite (§19) is the ratchet that
keeps it fixed — it **exists now** (`tests/journey_acceptance.rs`,
Stage 1a), seeded with steps 2, 3, and 5.
browsing); surfacing the LSP spawn failure with guidance (**in flight**:
Journey Stage 1b-2, §1.2); a compile keybinding + `cargo build`/`test`
default (**done**: Journey Stage 1b-1, #203, from the existing
`ProjectKind::Rust`**not** `Cargo`, see §24); a terminal keybinding
(**done**: `C-c t`, #173); a welcome buffer. The journey acceptance
suite (§19) is the ratchet that keeps it fixed — it **exists now**
(`tests/journey_acceptance.rs`, Stage 1a), seeded with steps 2, 3 and 5,
and carrying step 9 since #203.
Journey Stage 1b is the named remainder, and it splits: **1b-1 — the
compile binding + project-kind defaults — is in flight as PR #203**;
1b-2 (LSP spawn guidance, step 6) and 1b-3 (the welcome buffer, step 4)
remain. The ratchet is seeded with steps 2, 3 and 5, and gains step 9
when #203 lands.
compile binding + project-kind defaults — landed as #203**; **1b-2**
(LSP spawn guidance, step 6) is in flight; **1b-3**, the welcome buffer
(step 4), remains.
### Priority 2: Make workspace and location explicit
@ -1626,10 +1658,10 @@ implementation — this list is direction, not commitment):
`resolve_target_buffer` unification, the destination-scope substrate,
and the first journey acceptance suite. It routes `pmacs .` into
#165's dired buffer rather than growing a second directory surface.
**Stage 1b-1 — in flight (PR #203)**: the compile binding and
project-kind defaults, with the prompt capturing its directory rather
than re-resolving it at accept time. **Stage 1b-2 / 1b-3 —
remaining**: LSP-failure surfacing, welcome buffer.
**Stage 1b-1 — landed (#203)**: the compile binding and project-kind
defaults, with the prompt capturing its directory rather than
re-resolving it at accept time. **Stage 1b-2 — in flight**:
LSP-failure surfacing. **Stage 1b-3 — remaining**: welcome buffer.
2. **Discovery surface** (P4): the describe/list/where-is command
family, M-x rich rows, help unification, help prefix.
3. **Transient keymap layer** (§6): the overlay scope + lifetime
@ -1721,6 +1753,11 @@ available without being imposed.
Found during the audit; fix opportunistically, ideally before this
document is wired into CLAUDE.md/AGENTS.md as required reading:
- **§1.2's frequency note was wrong**, not merely stale: it recorded the
missing-server failure as firing once per project root when the real
rate was once per file open, because a failed spawn leaves no record
for the affinity loop to find. Corrected in place by Journey Stage
1b-2, along with three stale line citations in the same paragraph.
- **This document named a `ProjectKind` variant that does not exist**,
in two places: §2's step-9 row and §20 Priority 1 both said
"`ProjectKind::Cargo` existing (`src/project.rs:77`)". Line 77 is the
@ -1731,6 +1768,7 @@ document is wired into CLAUDE.md/AGENTS.md as required reading:
`pmacs.project.detect` returns the **tag string** `"rust"`. Kept here
rather than silently fixed, because a wrong type name in the document
work is evaluated against costs a scout a real detour.
- `docs/keybindings.md` — every `src/editor.rs` line citation in §3 is
stale by ~2501000 lines despite a "last verified @ `f8096ff`
(2026-07-20)" stamp; its shadow list also omits the terminal `C-c`

View File

@ -613,6 +613,163 @@ end
-- only after this module itself successfully spawns a server.
local default_servers = {}
-- ---------------------------------------------------------------------
-- Spawn-failure reporting (journey Stage 1b-2; COHERENCE §1.2)
-- ---------------------------------------------------------------------
--
-- `ensure_server`'s spawn `pcall` used to end in a bare `return nil`:
-- the canonical background failure — a preconfigured server that is not
-- installed — produced no status message, no record, and no modeline
-- marker, while tree-sitter highlighting kept working and masked it.
--
-- The reporting SHAPE is not new here. `root_resolver_for` and
-- `report_subscriber_error` in this same file already report through
-- `pmacs.editor.set_status` with the `pmacs.error` arm riding along;
-- this finishes that adoption at the site that matters most.
-- Lua strings are 8-bit clean, so \0 is a separator no language id,
-- URI, or command can contain.
--
-- The `u` / `n` discriminator is what makes the markerless case
-- expressible at all. `ensure_server`'s affinity key is `key_uri`, which
-- is deliberately **nil** for a file with no project marker (so loose
-- files share one server per language) — and `t[nil]` raises "table
-- index is nil". A bare `key_uri or ""` would instead collide with an
-- empty URI. One function for both tables, so the two encodings cannot
-- drift apart.
local function affinity_key(language, key_uri)
return language .. "\0" .. (key_uri and ("u" .. key_uri) or "n")
end
local function reported_key(language, key_uri, command)
return affinity_key(language, key_uri) .. "\0" .. tostring(command)
end
-- Session-scoped and NEVER cleared: has this exact (language, root,
-- command) triple already been named on the status line? The command is
-- part of the key so repointing config at a *different* missing
-- executable is a new failure and reports again.
local reported = {}
-- Current failure state per affinity key, CLEARED when a spawn for that
-- key succeeds. This is what `lsp.status` renders, so recovery makes it
-- go quiet. Separate from `reported` because one table cannot both
-- dedupe forever and forget on recovery.
local failures = {}
-- Per-buffer projection of `failures`, keyed by `tostring(buffer)`.
--
-- It exists so the modeline provider stays a PURE per-buffer lookup:
-- that provider runs for every window on every paint, and deriving an
-- affinity key inside it would invoke `project_root_for` — user root
-- resolvers and project detection — during painting.
--
-- Each entry carries the affinity key that produced it, so a success can
-- sweep every buffer sharing that key rather than only the one that
-- succeeded, and the path it was recorded against, so a rename or delete
-- can find it the way `attachments_under` finds attachments.
local failed_attachments = {}
-- Drop one buffer's projection, releasing its removal callback. Safe to
-- call for a buffer that has none.
local function clear_failed_buffer(bkey)
local entry = failed_attachments[bkey]
if not entry then return end
if entry.on_removed then
pcall(function() entry.on_removed:remove() end)
end
failed_attachments[bkey] = nil
end
-- A spawn for `key` succeeded: forget the failure and every buffer
-- projecting it.
--
-- Assigning nil to the CURRENT key during `pairs` is explicitly allowed
-- by Lua's `next` contract; adding a key would not be.
local function clear_failure(key)
failures[key] = nil
for bkey, entry in pairs(failed_attachments) do
if entry.key == key then clear_failed_buffer(bkey) end
end
end
-- Record and (at most once per identity) announce a spawn failure.
-- Returns the affinity key so the caller can project it onto a buffer.
local function report_spawn_failure(language, key_uri, command, err)
local key = affinity_key(language, key_uri)
failures[key] = {
language = language,
command = tostring(command),
error = tostring(err),
root_uri = key_uri,
}
local rkey = reported_key(language, key_uri, command)
if reported[rkey] then return key end
reported[rkey] = true
-- The underlying error names NEITHER the program nor the language:
-- `Command::spawn`'s io::Error becomes "spawn: No such file or
-- directory (os error 2)". So the guidance is composed here, where
-- both are still in scope, in the spirit of `src/main.rs`'s GPU
-- missing-binary message. The errno is passed through verbatim rather
-- than classified — EACCES is not "not installed".
local msg = string.format(
"LSP: %s for %s did not start (%s) — install it or set " ..
"pmacs.lsp.config.%s.command in init.lua. M-x lsp.status for detail.",
tostring(command), language, tostring(err), language)
pcall(pmacs.editor.set_status, msg)
if pmacs.error then pcall(pmacs.error, msg) end
return key
end
-- Project a failure onto the buffer that hit it, registering the
-- teardown ONCE. `attach_buffer` is reachable more than once for the
-- same buffer, so registering per failed attempt would stack callbacks
-- on one buffer — the same unbounded-registrar leak, moved rather than
-- fixed.
--
-- Without this registration the table would be bounded by nothing: a
-- killed buffer's entry would outlive it for the session, and no
-- existing cleanup could reach it, because `attachments_under` iterates
-- `attachments` and a failed buffer has no attachment by construction.
local function project_failure(buf, key, path)
local bkey = tostring(buf)
local existing = failed_attachments[bkey]
if existing then
existing.key = key
existing.path = path
return
end
local handle
local ok, h = pcall(pmacs.buffer.on_removed, buf, function()
-- The registry does `callbacks.take(id)` and then iterates an owned
-- list, so the entry is already gone: clear the projection, and do
-- NOT call `remove()` on our own handle from in here.
failed_attachments[bkey] = nil
end)
if ok then handle = h end
failed_attachments[bkey] = { key = key, path = path, on_removed = handle }
end
--- Current LSP spawn failures, newest-first is not meaningful here so
--- they are returned sorted by language for a stable render. Public
--- getter (per API conventions).
function pmacs.lsp.spawn_failures()
local out = {}
for _, f in pairs(failures) do
out[#out + 1] = {
language = f.language,
command = f.command,
error = f.error,
root_uri = f.root_uri,
}
end
table.sort(out, function(a, b)
if a.language ~= b.language then return a.language < b.language end
return tostring(a.root_uri) < tostring(b.root_uri)
end)
return out
end
local function ensure_server(language, path)
local cfg = pmacs.lsp.config[language]
if not cfg or not cfg.command then return nil end
@ -651,6 +808,8 @@ local function ensure_server(language, path)
and info.root_uri == key_uri then
local kind = info.state.kind
if kind ~= "crashed" and kind ~= "stopped" then
-- A live server for this key means there is no failure for it.
clear_failure(affinity_key(language, key_uri))
return info.id
end
end
@ -668,9 +827,13 @@ local function ensure_server(language, path)
})
if ok then
default_servers[tostring(sid)] = language
clear_failure(affinity_key(language, key_uri))
return sid
end
return nil
-- `sid` holds the error on the failing branch. Second return value is
-- the affinity key, so `attach_buffer` can project it onto the buffer
-- without recomputing a root.
return nil, report_spawn_failure(language, key_uri, cfg.command, sid)
end
-- Internal ownership seam for builtins whose lifecycle follows the
@ -853,8 +1016,17 @@ local function attach_buffer(buf)
-- Path resolved before spawn so the server's `rootUri` can be
-- derived from the file's project (see `project_root_for`).
local path = active_buffer_path()
local sid = ensure_server(language, path)
if not sid then return nil end
local sid, failure_key = ensure_server(language, path)
if not sid then
-- Journey Stage 1b-2: remember WHY there is no attachment, so the
-- modeline can say "failed" instead of rendering nothing — which is
-- indistinguishable from "this file type has no server".
if failure_key then project_failure(buf, failure_key, path) end
return nil
end
-- Any successful resolution clears this buffer's stale projection;
-- `clear_failure` has already swept peers sharing the affinity.
clear_failed_buffer(key)
local uri = file_uri_for(path)
if not uri then return nil end
local rec = {
@ -976,9 +1148,14 @@ pmacs.statusline.register {
priority = 0,
face = "ui.modeline.lsp",
fn = function(ctx)
local rec = attachments[tostring(ctx.buffer)]
if not rec then return nil end
return "LSP:" .. pmacs.lsp.modeline_label(rec.server)
local bkey = tostring(ctx.buffer)
local rec = attachments[bkey]
if rec then return "LSP:" .. pmacs.lsp.modeline_label(rec.server) end
-- Journey Stage 1b-2. A plain map lookup, deliberately: deriving an
-- affinity key here would run root resolvers and project detection
-- once per window per paint.
if failed_attachments[bkey] then return "LSP:!" end
return nil
end,
}
@ -2767,6 +2944,58 @@ end
-- Default commands + keymap entries --------------------------------------
-- Journey Stage 1b-2. `LspManager::status_buffer_text()` and
-- `last_error` have existed and been exposed to Lua since M4.8, with no
-- production caller and no buffer to render into — several doc comments
-- in `src/lsp.rs` refer to "the `*lsp*` buffer" as though it existed.
-- This is that buffer.
--
-- Opened through `pmacs.listview.open` rather than hand-rolled, which is
-- what buys owned-handle identity (a foreign `*lsp*` is never adopted),
-- `<2>` collision behaviour, an immutable generated buffer, `q`, and
-- `g`. `on_refresh` is NOT optional: `listview.refresh` early-returns
-- without one, which would leave `g` bound and silently dead.
local function lsp_status_rows()
local rows = {}
local fails = pmacs.lsp.spawn_failures()
if #fails > 0 then
rows[#rows + 1] = { text = string.format("Failed to start (%d):", #fails) }
for _, f in ipairs(fails) do
rows[#rows + 1] = { text = string.format(" %s (%s) — %s",
f.command, f.language, f.error) }
if f.root_uri then
rows[#rows + 1] = { text = " root: " .. f.root_uri }
else
rows[#rows + 1] = { text = " root: (none detected)" }
end
end
rows[#rows + 1] = { text = "" }
end
rows[#rows + 1] = { text = "Servers:" }
local ok, text = pcall(pmacs.lsp.status_buffer_text)
if ok and type(text) == "string" then
for line in (text .. "\n"):gmatch("([^\n]*)\n") do
rows[#rows + 1] = { text = line }
end
else
rows[#rows + 1] = { text = " (status unavailable)" }
end
return rows
end
pmacs.command.define {
name = "lsp.status",
description = "Show language-server status and start failures in *lsp*.",
fn = function()
pmacs.listview.open {
name = "*lsp*",
header = "LSP status g refresh q quit",
rows = lsp_status_rows(),
on_refresh = lsp_status_rows,
}
end,
}
pmacs.command.define {
name = "lsp.go-to-definition",
description = "Jump to the definition of the symbol under the cursor (LSP).",
@ -2942,6 +3171,22 @@ local function attachments_under(path)
return out
end
-- Journey Stage 1b-2: the same query for FAILED buffers, which have no
-- attachment by construction and are therefore invisible to
-- `attachments_under`. Matches on the path the projection was recorded
-- against, exactly as the attachment query matches on the cached
-- `rec.uri` — the buffer's own path has already been rebound by the time
-- `resource.renamed` fires.
local function failed_projections_under(path)
local out = {}
for bkey, entry in pairs(failed_attachments) do
if entry.path and paths_related(entry.path, path) then
out[#out + 1] = bkey
end
end
return out
end
-- How many attributed failures one status line spells out before
-- collapsing the rest into a count.
local RESOURCE_REPORT_LIMIT = 2
@ -3003,6 +3248,16 @@ end
pmacs.hook.add("resource.renamed", function(old_path, new_path)
if type(old_path) ~= "string" or type(new_path) ~= "string" then return end
-- A failed buffer's projection asserts "this buffer's server failed
-- for affinity K". After a rename that is no longer known to hold —
-- the new path may be in a different project, or none — so it is
-- CLEARED rather than re-keyed. Re-keying would assert a failure at a
-- location where none was observed. Clearing degrades to "we no longer
-- know", which renders as no marker; the next attach re-establishes
-- the truth.
for _, bkey in ipairs(failed_projections_under(old_path)) do
clear_failed_buffer(bkey)
end
local sink = failure_sink("resource.renamed")
for _, hit in ipairs(attachments_under(old_path)) do
local key, rec, old_uri = hit.key, hit.rec, hit.rec.uri
@ -3073,6 +3328,11 @@ end)
pmacs.hook.add("resource.deleted", function(path)
if type(path) ~= "string" then return end
-- Same disposition as rename, for the stronger reason that the path is
-- gone entirely.
for _, bkey in ipairs(failed_projections_under(path)) do
clear_failed_buffer(bkey)
end
local sink = failure_sink("resource.deleted")
for _, hit in ipairs(attachments_under(path)) do
local key, rec = hit.key, hit.rec

View File

@ -62,17 +62,19 @@ lesson, §1 for the two framings).
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot:
`githubsucks/main` @ `7586905` (the docs-only coherence listview
correction #189, atop the docs-only landed-state refresh #185, the M4
config-sink race fix #174, bottom-panel Stage 2B-1 #184, the
Journey/GPU directory-target ratchet #183, Journey Stage 1a #182 and
the previously recorded landed work).
`githubsucks/main` @ `fbcf235` (the reap-ledger diagnostic #202, atop
the test ambient-root isolation framing #201, the reap-ledger framing
#200, the ledger absorption #199, the M5.5 protocol-version pin #198,
the docs-only coherence listview correction #189, the docs-only
landed-state refresh #185, the M4 config-sink race fix #174,
bottom-panel Stage 2B-1 #184, the Journey/GPU directory-target ratchet
#183, Journey Stage 1a #182 and the previously recorded landed work).
**Protocol schema support is
`v6..=v21`; the production server-first `Hello` still advertises
v20** — two different facts, and #184 landed only the first. The
previous snapshot named `0442d78`, and **the
previous snapshot named `7586905`, and **the
recovery floor advances with it**: the check below now requires
`7586905` or newer, so a tree at `0442d78` no longer passes. That is
`fbcf235` or newer, so a tree at `7586905` no longer passes. That is
deliberate — the floor moves with the base, because a check that
accepts an older commit than the declared base passes on a tree the
rest of this file does not describe.
@ -111,7 +113,7 @@ git worktree list
git status --short --branch
```
The `git log` command must expose `7586905` — the base named above — or a
The `git log` command must expose `fbcf235` — the base named above — or a
newer intentional main. Keep this threshold and the canonical-base line in
step: a recovery check that accepts an older commit than the base it
declares canonical will pass on a tree the rest of this file does not
@ -253,105 +255,79 @@ If it does not, stop and repair the remote/fetch configuration.
never been enforced. Any CI job that compiles the `crdt` targets has to
fix them first or it will be red on arrival.
## Journey lane (P1) — STAGE 1a MERGED; STAGE 1b-1 IMPLEMENTED, PR OPEN
## Journey lane (P1) — 1a and 1b-1 MERGED; 1b-2 PR OPEN; 1b-3 REMAINS
- **Branch `journey-stage1b1-compile-defaults`**, worktree
`../pmacs-journey-1b1`, based on `githubsucks/main` @ `22df6ab`.
`docs/journey-stage1b1-compile-defaults-framing.md` revision 2,
approved after one review round (two blocking, two major, all
accepted). **Implemented; PR open.**
- **ON MERGE OF #203, flip four places to landed.** The PR deliberately
ships them as *in flight*, because `COHERENCE.md` §25 says a grade
changes only on landed evidence and the PR is open: `COHERENCE.md`
§2's step-9 verdict row (Partial → **Works**), §2's post-table
keybinding-inversion paragraph (third example answered; the build is
no longer its uncontested example), §20 Priority 1 and its arc list (in
flight → done), and
`docs/agent-handoff.md` §1's arc bullet (IMPLEMENTED → LANDED).
**Recorded here because an unowned doc flip is exactly how this
ledger's drift starts** — the same rule-4 precondition that kept #176's
lane alive past its merge.
- **A lexical path expectation is wrong for anything detection touched.**
`pmacs.project.detect` canonicalizes before walking
(`canonicalize_or_passthrough`, `src/project.rs:509-511`) while the
suite's `canon()` is lexical, so the compile-directory assertions
passed on Ubuntu and **failed both macOS legs**, where `/var` is a
symlink to `/private/var`. Fixed with a `detected_root()` expectation
and pinned by a **symlinked fixture**, which reproduces the disagreement
on any platform — a Linux-only bite could not have caught it.
- **Bites found two vacuous pins of my own.** The nested-project pin
passed with the keybinding removed, because `minibuffer.contents()` is
`""` both for an empty prefill and for no minibuffer at all — `""`
compared with `""`. And the hostile-`defaults` pin called
`compile.run` directly, so it never consulted `defaults` and passed
with the guard removed. Both now assert their precondition.
- **Round 1's blocking finding is the Stage 1a lesson repeating.**
Sharing one cwd resolver between the prompt and the run is *not*
enough: `pmacs.minibuffer.read` is async, the active window can change
while the prompt is open, and `run` re-resolved at accept time — so
the prompt could offer `cargo build` for A and execute in B. The
interactive command now **captures** `context()` and passes its `cwd`
through to `run`, which is `commit_to`'s discipline on a smaller seam.
The second blocker was that no pin crossed the accept boundary at all,
so that defect passed every proposed pin.
- **Why this lane exists.** `COHERENCE.md` §20 Priority 1 names Journey
Stage 1b as the golden journey's remainder — "the compile binding +
Cargo defaults, LSP spawn guidance, and the welcome buffer" — and it
had no branch, no framing, and no lane. It is the only §20 priority
with nothing in flight.
- **What 1b-1 is.** Journey step 9 only: a global `C-c c` for
`compile.run`, and a first prompt prefilled from the detected project
kind instead of empty. Lua, tests, and docs; **no Rust change and no
protocol change**. 1b-2 (LSP spawn guidance, step 6) and 1b-3 (welcome
buffer, step 4) are separate stages — see the framing §7 for why the
three are not one PR.
- **`ProjectKind::Cargo` does not exist.** `COHERENCE.md` names it twice
(§2's step-9 row and §20 Priority 1); the variant is
`ProjectKind::Rust` (`src/project.rs:78`) and line 77 is its doc
comment. The audit read the comment. Lua matches on the **tag string**
`"rust"` that `pmacs.project.detect` returns, so no Rust primitive is
needed. The PR corrects both COHERENCE sites.
- **The suggestion and the run must share one cwd resolution.** Today
the cwd is computed *inside* `pmacs.compile.run`
(`builtin/runtime/compile.lua:765`), after the prompt has closed, so a
suggestion computed in the command's `fn` would obey a different rule
than the run. The stage extracts it and exposes
`pmacs.compile.context()`, consumed by both.
- **The trap the acceptance is designed around.** The last-resort cwd is
`std::env::current_dir()` evaluated at call time
(`pmacs-protocol/src/message.rs:1703-1706`), so in tests it is the
**test runner's cwd — the pmacs repo root, itself a Cargo project**.
`compile_mode_acceptance.rs:1721` already pins this. A pin asserting
"no Cargo suggestion" that reaches the fallback would report pmacs's
own `Cargo.toml`. Negative pins therefore use a fixture carrying a
*different* marker so the fallback is never consulted;
`set_search_boundary` does **not** help here, because it only clamps a
walk that starts below the boundary.
- **Known limitation, deliberately not fixed.** After `pmacs <dir>` the
active buffer is dired's and **pathless** (`pmacs.buffer.create` never
assigns a path; dired compensates through its own module-local
`handle_for_buffer`, `dired.lua:205-217`), so the cwd falls through to
the process cwd. Launched from elsewhere that is the wrong directory.
The fix is `COHERENCE.md` §8 (First-Class Execution Locations), a
model gap; reaching into dired's private table for one string would
add an interaction island.
**Rewritten, not removed, at #203's merge.** Rule 4 removes a lane when
its ARC is done; the journey arc is not — 1b-2 is in flight and 1b-3 is
unframed. Stage 1a (#182/#183) and Stage 1b-1 (#203) are on `main` and
their durable facts are in `docs/agent-handoff.md` §1, which is rule 4's
precondition satisfied rather than deferred.
**#203's merge obligation is DISCHARGED**: `COHERENCE.md` §2's step-9
row now reads **Works**, §2's keybinding-inversion paragraph records all
three examples answered (the quote itself deliberately unchanged), §20
Priority 1 and its arc list say "landed", and the handoff bullet says
LANDED. That flip rides *this* branch rather than a standalone docs PR,
because #204 already touches all three files and a separate PR would
re-conflict on every merge.
- **Branch `journey-stage1b2-lsp-guidance`**, worktree
`../pmacs-journey-1b2`, based on `githubsucks/main` @ `fbcf235`,
**integrated with `main` @ `1f290d5` (#203)**.
`docs/journey-stage1b2-lsp-guidance-framing.md` revision 4, three
review rounds closed (round 1: two blocking, three major, one minor;
round 2: two blocking, two cleanups; round 3: one blocking; all
accepted). **Implemented; PR #204 open.**
- **What it is.** `COHERENCE.md` §1.2's canonical silence, journey step
6: a preconfigured-but-missing language server now reports with
guidance, marks the modeline `LSP:!`, and appears in `M-x lsp.status`.
- **Half of it was already built and unwired.**
`LspManager::status_buffer_text()` and `last_error()` have existed
since M4.8, exposed to Lua and tested, with **no production caller**
and no `*lsp*` buffer, while several `src/lsp.rs` and `src/project.rs`
doc comments refer to that buffer as though it existed.
- **The reporting shape was already adopted twice in `lsp.lua` itself**
(root resolvers, notification subscribers). The canonical case was
silent because nobody had converted it — this finishes an adoption.
- **`COHERENCE.md` §1.2's frequency note was wrong, and it decided the
design.** `LspManager::spawn` returns early *before* both
`status_tracker.ensure` and `clients.insert`, so a failed spawn leaves
**no record**, `pmacs.lsp.list()` cannot see it, and the affinity loop
re-spawns: the real rate is **once per file open**, not once per
project root. Hence **memoize the report, not the failure**.
- **The affinity key is `(language, key_uri)`, and `key_uri` is nil for
markerless files**, which deliberately share one server per language.
Lua cannot index by nil (`t[nil]` raises), so one encoding function
serves both tables with a `u`/`n` discriminator no URI can collide
with.
- **Three tables, three lifetimes**, plus a buffer-keyed projection for
the modeline — that provider runs for every window on every paint, so
deriving an affinity key inside it would invoke user root resolvers
during painting. **A success sweeps every projection sharing the key**,
and the projection has its own `pmacs.buffer.on_removed` teardown
because nothing existing reaches it (`attachments_under` iterates
`attachments`, and a failed buffer has none by construction).
- **ON MERGE of #204, flip the step-6 grade.** §2's step-6 row stays
**Partial** while the PR is open, per §25's landed-evidence rule, and
says so in the row.
- **Stage 1b-3 (welcome buffer, step 4) is unframed** — the last of the
1b split.
- Recovery from a clean checkout — **the two-argument form does not
work** (`git worktree add <path> <remote-only-branch>` fails with
`fatal: invalid reference`, because after a bare fetch no local branch
exists):
`fatal: invalid reference`):
```sh
git fetch githubsucks
git worktree add ../pmacs-journey-1b1 \
-b journey-stage1b1-compile-defaults \
githubsucks/journey-stage1b1-compile-defaults
git worktree add ../pmacs-journey-1b2 \
-b journey-stage1b2-lsp-guidance \
githubsucks/journey-stage1b2-lsp-guidance
```
## Journey Stage 1b-3 (P1) — FRAMING OPEN, revision 4
## Journey Stage 1b-3 (P1) — IMPLEMENTED, PR OPEN
- **Branch `journey-stage1b3-welcome`**, worktree `../pmacs-journey-1b3`,
based on `githubsucks/main` @ `1f290d5`. **Framing only; no code, no
PR yet.** `docs/journey-stage1b3-welcome-framing.md` revision 4, three
`docs/journey-stage1b3-welcome-framing.md` revision 4, three
review rounds closed (round 1: four findings; round 2: two acceptance
holes plus a doc correction; round 3: a visibility mismatch and an
unobservable assertion; all accepted). The last of the 1b split
@ -428,10 +404,21 @@ If it does not, stop and repair the remote/fetch configuration.
refused a completion source for exactly this reason) and `accept()`
does `session.take()`, so nothing about the accepted value survives
afterwards.
- **Integrate late.** #204 has landed at `5376af1`; this lane still
touches `COHERENCE.md`, `docs/agent-handoff.md` and
`docs/active-work.md`, so merge `main` at PR time rather than opening
a standalone refresh PR.
- **Integrated with `main` @ `5376af1`** (#204). The journey suite's
conflict was additive on both sides — step 4 (this lane) and step 6
(#204) — and both are kept: **44 pins, covering steps 2, 3, 4, 5, 6
and 9**.
- **§18 and the scorecard move Missing → Partial ON THIS PR**, per §25;
§2's step-4 row stays Partial because `C-h` and the tutorial remain.
No deferred flip is owed at merge — unlike 1b-1's.
- **Bites, all directed.** Deleting the production `run()` wiring fails
the two greeting pins — **the mutation revision 2's design would have
survived entirely**. Removing `mark_clean` fails the editable/clean
pin; the `had_file` guard fails the directory pin (not the file pin,
because a file buffer is active by then while the dired listing is
async); the emptiness guard fails the existing-content pin; the
active-buffer requirement fails the backgrounded pin; and advertising
an unbound key fails the binding pin.
```sh
git fetch githubsucks

View File

@ -104,11 +104,9 @@ commands, read `docs/active-work.md` immediately after this file.
interaction islands added, config-registry adoption, background-work
attribution. Its §2 grades the golden journey; **Journey Stage 1a
moved that grade off "broken at step 3"** — see the arc bullet below.
- **Journey arc (P1) — Stage 1b-1 IMPLEMENTED, PR #203 OPEN**
(`docs/journey-stage1b1-compile-defaults-framing.md`). Not landed: per
`COHERENCE.md` §25 a grade changes only on landed evidence, so §2's
step-9 row still reads **Partial** and flips at merge.
Journey step 9 will move **Partial → Works**: `C-c c` runs `compile.run`,
- **Journey arc (P1) — Stage 1b-1 LANDED (#203)**
(`docs/journey-stage1b1-compile-defaults-framing.md`).
Journey step 9 moves **Partial → Works**: `C-c c` runs `compile.run`,
and the first prompt is prefilled from the detected project kind via
`pmacs.compile.defaults` (seeded `rust = "cargo build"`, extensible
from `init.lua`). Lua, tests and docs; no Rust change, no protocol
@ -302,6 +300,77 @@ commands, read `docs/active-work.md` immediately after this file.
disagree — and it still establishes no identity, because it is read
inside the same read-then-act window and no portable mechanism closes
that for a *group* (`pidfd` covers a process; macOS has neither).
- **Journey arc (P1) — Stage 1b-3 IMPLEMENTED, PR open**
(`docs/journey-stage1b3-welcome-framing.md`, rev 4, three review
rounds). The last of the 1b split. An unconfigured launch greets in
`*scratch*`; `M-x help` renders a cheat sheet. **§18 and the scorecard
move Missing → Partial**; §2's step-4 row stays Partial.
- **No constructor is the startup hook.** `EditorState::open` calls
`new` *before* resolving its target, the daemon constructs one too,
`init.lua` runs inside `new`, and desktop restore happens later
still. `run()`'s terminal-free prefix is now `prepare_startup`, and
the greeting is its last step.
- **Extraction is what makes wiring testable.** With the seam called
by hand from tests, deleting the production call left every
assertion green while shipping no welcome — the "guard with no
production caller" shape. `prepare_startup` is `pub` because the
journey suite is a separate crate and the rest of the sequence
(`run`, `new`, `open`, `install_state_dirs`,
`restore_desktop_if_armed`) is already public.
- **`C-h` is not free**, and §2's step-4 row used to imply it was: it
deletes a word because non-kitty terminals cannot disambiguate
Ctrl+Backspace from Ctrl+H (both byte 0x08). Rebinding it to a help
prefix breaks Ctrl+Backspace on every legacy terminal. Deferred to
the discovery arc **with the reason recorded**.
- **Deliberately NOT `set_generated_contents`** — it would lift
read-only, discard history and mark the buffer generated, all wrong
for a buffer step 5 requires the user to type into immediately. The
one place not adopting that invariant is correct.
- **`pmacs.command.invoke` is not the M-x path.** M-x is
`editor.execute-command`, a minibuffer with the `commands`
completion source calling `invoke_interactive` on accept — and
because a selected candidate shadows typed text while `accept()`
does `session.take()`, the pin asserts
`pmacs.minibuffer.selected() == "help"` **before** RET.
- **Journey arc (P1) — Stage 1b-2 IMPLEMENTED, PR open**
(`docs/journey-stage1b2-lsp-guidance-framing.md`, rev 4, three review
rounds). `COHERENCE.md` §1.2's canonical silence: a preconfigured
language server that is not installed now reports with guidance, marks
the modeline `LSP:!`, and appears in `M-x lsp.status`. Per §25 the
step-6 grade flips only on merge.
- **`status_buffer_text()` had existed since M4.8, exposed to Lua and
tested, with no production caller and no `*lsp*` buffer** — several
`src/lsp.rs` and `src/project.rs` doc comments referred to that
buffer as though it existed. Half the stage was wiring dark matter.
- **The reporting shape was already adopted twice in `lsp.lua`** (root
resolvers, notification subscribers). The canonical case was silent
because nobody had converted it, not for want of a mechanism.
- **A failed spawn leaves NO record**: `LspManager::spawn` returns
early before both `status_tracker.ensure` and `clients.insert`, so
`pmacs.lsp.list()` cannot see it and the affinity loop re-spawns.
The failure therefore recurs **once per file open**, not once per
project root as COHERENCE recorded. Hence: **memoize the report, not
the failure** — the spawn is still retried, so installing the binary
mid-session recovers with nothing to invalidate.
- **The affinity key is `(language, key_uri)` and `key_uri` is nil for
markerless files**, which deliberately share one server per
language. Lua cannot index by nil, so one encoding function serves
both tables with a `u`/`n` discriminator no URI can collide with.
- **Three tables, three lifetimes.** `reported` (never cleared,
includes the command so repointing at another missing executable
re-reports); `failures` (cleared on a successful spawn for that
key); and a **buffer-keyed projection** for the modeline, because
that provider runs for every window on every paint and deriving an
affinity key inside it would invoke root resolvers during painting.
- **A success must SWEEP the projections, not clear one.** Clearing
only the succeeding buffer leaves an earlier buffer rendering
`LSP:!` while `lsp.status` reports nothing wrong.
- **A new per-buffer table needs its own teardown.** Nothing existing
reaches it: the LSP resource reconciliation iterates `attachments`,
and a failed buffer has none by construction. `pmacs.buffer.on_removed`
is registered once per projection; rename and delete **clear** rather
than re-key, because after a rename the failure is no longer known to
apply at the new location.
- **Reap-ledger silent failures — DIAGNOSTIC, in flight**
(`docs/reap-ledger-silent-failures-framing.md`). The lane #200's
framing §5 parked and its evidence unparked. **Four `kill(2)` results

View File

@ -0,0 +1,781 @@
# Journey Stage 1b-2 — say when language intelligence did not start
**Status: framing, rev 4 — awaiting review round 4.**
**Serves `COHERENCE.md` §1.2 (the silence asymmetry), §2 (the golden
journey, step 6), §19, §20 Priority 1.**
## 0. Revision history
- rev 4 (2026-07-30) — review round 3. One blocking lifecycle gap,
accepted; the registry's dispatch order was read before specifying the
fix.
- **`failed_attachments` had no removal path.** It is keyed by
`tostring(buf)` and rev 3 never said who deletes an entry, so killing
a failed buffer leaked its projection **for the session**. That also
made rev 3's sweep bound — "at most the number of open buffers" —
false, since the table could exceed the number of buffers that
exist.
- **Nothing else would have cleaned it incidentally.** The LSP
resource reconciliation iterates `attachments` via
`attachments_under` (`lsp.lua:2934-2944`), and a failed buffer has
no attachment **by construction** — that is the whole reason the
projection exists. So the gap could not be closed by an existing
subscriber; it needed its own registration.
- Rev 4 adds a per-projection `pmacs.buffer.on_removed` registration
(§2.5), a **stated disposition on rename and delete** (§2.6) — clear
it, rather than leaving an old-path failure projected onto a changed
buffer — an amended sweep bound that now *follows* from the cleanup
rather than being asserted beside it, and acceptance rows 1416.
- rev 3 (2026-07-30) — review round 2. Two blocking, two cleanups; all
four accepted, and the two blockers verified by running Lua rather
than by reading it.
- **Recovery was inconsistent across buffers that share an affinity.**
Rev 2 cleared `failures[K]` on success but cleared only the
*succeeding* buffer's projection. So: buffer A fails, buffer B
succeeds for the same `(language, key_uri)``M-x lsp.status` says
the failure is gone while **A's modeline still reads `LSP:!`**. Rev
2's claim that the two tables are "written and cleared at the same
moment" was simply false for the cross-buffer case, which is the
normal case for a project with more than one file. Each buffer
projection now carries its affinity key, and a success **sweeps every
projection holding that key** (§2.5). Acceptance 9 pins it.
- **The markerless key had no Lua representation.** `key_uri` is
deliberately nil, and `t[nil] = v` raises *"table index is nil"*
confirmed by running it, in both LuaJIT and 5.4. So rev 2's central
markerless criterion was literally unimplementable as written, and
left to implementation would have invited two different ad-hoc
encodings for the two tables. §2.2 now prescribes **one** key
function, used by both.
- **Acceptance 10 could not have observed what it claimed.** Making the
command resolvable changes no state by itself; `failures` is cleared
by a *successful spawn*, which needs an attach to occur. The pin now
reattaches before pressing `g`.
- The ledger heading still said revision 1.
- rev 2 (2026-07-30) — review round 1. Two blocking, three major, one
minor; all six accepted, all six verified in the code first.
- **The affinity key was misstated** (§2.2). The runtime's reuse key
is `(language, key_uri)`, and `key_uri` is deliberately **nil** for a
markerless file — `ensure_server` sets it only when the root came
from config or a marker walk (`lsp.lua:644-648`), so loose files
across unrelated directories share **one** server per language, on
purpose. Rev 1 said "(root, language)", which would have given every
directory of loose files its own memo entry and re-reported the same
shared failure once per directory. The stage now keys on the real
affinity key and does not change that behaviour.
- **Dedupe and current-failure state were conflated** (§2.2, §2.4).
Rev 1 had one record and said nothing about recovery. One record
cannot do both jobs: keep it and `*lsp*` shows a failure that has
since been fixed; clear it and the per-session dedupe is lost, so
the message returns on the next file open. They are now two records
with different lifetimes, and **the reported identity includes the
command**, so repointing config from one missing executable to
another reports again. Recovery is pinned in both surfaces.
- **The modeline's pure per-buffer projection had to be preserved**
(§2.5). The provider reads `attachments[ctx.buffer]` specifically so
a passive split reports its own buffer, and does no work while
painting. Rev 1's "read the failure table" would have made the
segment recompute an affinity key — invoking `project_root_for`,
user root resolvers, and `pmacs.project.detect` **every frame, for
every window**. The failure is now projected per buffer at attach
time, and the segment stays a single map lookup.
- **"Adopt listview's idiom" was too weak** (§2.3). It now requires
`pmacs.listview.open` and names the guarantees that come with it,
including `on_refresh` — without which `listview.refresh` early
returns (`listview.lua:259`) and `g` is a **dead key** in the new
panel.
- **§19's journey ratchet was missing from acceptance** (§4). This
stage makes step 6 real, and `tests/journey_acceptance.rs`'s stated
rule is that steps 612 join as later stages make them real. The M4
pins stay; an end-to-end journey row is added.
- **The ledger's canonical-base anchor was stale** — it named
`7586905` while `main` is `fbcf235`. Updated with the recovery
floor, which moves with it.
- rev 1 (2026-07-30) — first framing. Scouted against `githubsucks/main`
@ `fbcf235` (reap-ledger #202).
## 1. Ground truth
Everything below was read in the tree at `fbcf235`. Where
`COHERENCE.md`'s audit (2026-07-25) is now stale, §1.7 says so.
### 1.1 The canonical failure is still completely silent
`ensure_server` (`builtin/runtime/lsp.lua:616`) ends:
```lua
local ok, sid = pcall(pmacs.lsp.spawn, { … })
if ok then
default_servers[tostring(sid)] = language
return sid
end
return nil
```
`return nil` is the whole error path. No status line, no record, no
event. The `buffer.after-load` hook then swallows what is left:
```lua
-- builtin/runtime/lsp.lua:1019-1021
pmacs.hook.add("buffer.after-load", function()
pcall(attach_buffer, pmacs.window.buffer())
end)
```
This is the case a new user hits first: `rust-analyzer` is
preconfigured (`lsp.lua:44-52`) and, on most machines, not installed.
### 1.2 The reporting pattern is already established — in this same file, twice
The fix is not "invent a channel". `lsp.lua` already does exactly the
right thing at two other sites:
| Site | Failure reported |
|---|---|
| `lsp.lua:570-585` | a root resolver that raised or returned a bad type |
| `lsp.lua:1831-1836` (`report_subscriber_error`) | a notification subscriber that raised |
Both use the same shape, and both carry the reasoning in comments:
```lua
pcall(pmacs.editor.set_status, msg)
if pmacs.error then pcall(pmacs.error, msg) end
```
`pmacs.editor.set_status` is the channel that exists; the `pmacs.error`
arm rides along for when that channel is built. **So the canonical case
is not silent for want of a mechanism — it is silent because the two
sites that adopted the rule were the two that a review happened to
touch.** That is worth stating plainly: this stage finishes an adoption,
it does not start one.
### 1.3 The error string does not name the command
The failure text a caller receives is built at
`src/process.rs:2061`:
```rust
let mut child = cmd.spawn().map_err(|e| format!("spawn: {e}"))?;
```
On a missing binary that renders as:
```
spawn: No such file or directory (os error 2)
```
**It names neither the program nor the language.** `std::io::Error` from
`Command::spawn` carries no program name, and nothing between there and
Lua adds one. So a message built by forwarding the error verbatim would
tell a user nothing actionable — the guidance has to be composed at the
site that still knows `language` and `cfg.command`.
The model to imitate is in this repo already, and `COHERENCE.md` §1.2
calls it "the best missing-tool message in the codebase"
(`src/main.rs:367-379`): it names the sibling path it tried *and* the
PATH fallback, so the reader knows what was attempted and where to look.
### 1.4 There is no negative memo — the failure repeats per file open
`COHERENCE.md` §1.2's frequency note says the failure "fires **once per
project root** rather than once per language per session". **That is not
what the code does, and the difference decides this stage's hardest
question.**
`LspManager::spawn` (`src/lsp.rs:1287-1297`) returns early on failure:
```rust
let id = LspServerId::next();
let mut client = LspClient::new(spec);
self.start_generation(id, &mut client)?; // <-- returns here on ENOENT
self.status_tracker.ensure(id, Instant::now());
self.clients.insert(id, client);
```
Both the status-tracker entry and the client insert are *after* the `?`.
So a failed spawn leaves **nothing** — no client, no status record — and
`pmacs.lsp.list()` cannot see it. `ensure_server`'s affinity loop scans
exactly that list, finds nothing, and spawns again.
`attach_buffer` runs from `buffer.after-load`, so the real frequency is
**once per file open of a matching language**, which is strictly more
often than the audit recorded. A naive "report the failure" would put a
status-line message on every single file open in a Rust project.
**This is why "when to speak" is the design question and not a detail.**
### 1.5 The modeline cannot distinguish "failed" from "not applicable"
```lua
-- builtin/runtime/lsp.lua:973-983
fn = function(ctx)
local rec = attachments[tostring(ctx.buffer)]
if not rec then return nil end
return "LSP:" .. pmacs.lsp.modeline_label(rec.server)
end,
```
No attachment record means no segment at all. A `.rs` file whose server
failed to spawn and a `.txt` file that never had one render identically,
while tree-sitter highlighting keeps working — §1.2's "highlighting
**actively masks** the failure".
### 1.6 The status surface is already built, and has no caller
**This is dark matter, and it makes half the stage free.**
- `LspManager::status_buffer_text()` (`src/lsp.rs:1257`) — doc comment:
*"render the contents of the `*lsp*` status buffer"*.
- `LspManager::last_error(sid)` (`src/lsp.rs:1251`).
- Both are exposed to Lua: `pmacs.lsp.status_buffer_text()`
(`src/lua_bindings/mod.rs:10949`) and `pmacs.lsp.last_error`
(`:10851`), plus per-server `last_error` inside the status table
(`:10931-10938`).
- Both are **tested** (`tests/m4_acceptance.rs:2545`, `:2634-2640`).
And there is **no production caller of any of them**, no `*lsp*` buffer,
and no interactive command: the twelve `lsp.*` commands
(`lsp.lua:2771-2840`) are all feature actions — definition, hover,
rename, format — and not one is diagnostic. Several doc comments across
`src/lsp.rs` and `src/project.rs` refer to "the `*lsp*` buffer" as
though it exists.
So `COHERENCE.md` §2 step 6's "No LSP status command exists to
diagnose" is true, but understates the position in the stage's favour:
the renderer exists, is exposed, and is tested. What is missing is a
command and a buffer.
**One thing it will not show, however:** `status_buffer_text` renders
from `self.clients`, and §1.4 established that a failed spawn inserts no
client. **The durable surface cannot, today, display the very failure
this stage exists to surface.** §2.4 decides what to do about that.
### 1.7 Stale citations in `COHERENCE.md` §1.2
Recorded so a later scout does not lose time, and corrected by this
stage (§6):
- "`ensure_server` `pcall`s it and returns nil
(`builtin/runtime/lsp.lua:614-626`)" — the function starts at `:616`
and the spawn/`pcall` is at `:658-674`.
- "the `buffer.after-load` hook `pcall`s the whole attach
(`builtin/runtime/lsp.lua:895-897`)" — it is `:1019-1021`.
- The frequency note is wrong in kind, not just in line number (§1.4).
- "**Net user-visible result: nothing**" remains **true** for this
failure, but the surrounding claim that no background failure is
reported is now false — §1.2 above lists two sites that do.
## 2. Design
### 2.1 What to say
Composed where `language` and `cfg.command` are still in scope, in
`src/main.rs`'s spirit — name what was tried, and what to do:
```
LSP: rust-analyzer for rust did not start (spawn: No such file or
directory (os error 2)) — install it or set
pmacs.lsp.config.rust.command in init.lua. M-x lsp.status for detail.
```
Three parts, each load-bearing:
1. **What was attempted** — the command and the language. §1.3 shows the
underlying error supplies neither.
2. **The underlying error, verbatim** — so a permissions failure or a
bad interpreter is distinguishable from a missing file. The message
must not *classify* the errno; §1.3's string is all we get, and
guessing "it is not installed" would be wrong for `EACCES`.
3. **The two things a user can do** — install it, or repoint the config
— plus the durable surface.
### 2.2 When to say it — and two records, not one
Per §1.4 the failure recurs on every file open, so the report is
memoized. Two things have to be got right, and rev 1 got both wrong.
**The key is `ensure_server`'s own affinity key, which is not the
root.** `ensure_server` computes:
```lua
-- builtin/runtime/lsp.lua:644-648
local root, source = project_root_for(language, path)
local key_uri = nil
if source == "config" or source == "detected" then
key_uri = file_uri_for(root)
end
```
`key_uri` is **nil** whenever the root came from the fallback (the
file's own directory), and the reuse loop matches `info.root_uri ==
key_uri`, nil matching nil. That is deliberate and documented in place:
loose files in unrelated directories share **one** server per language,
because keying them on their own directories "would give every directory
of loose scratch files its own server, for every language".
So the memo key is `(language, key_uri)` — the same pair, nil included —
and **not** the resolved root. Keying on the root would split what the
runtime deliberately shares and re-report one shared failure once per
directory. This stage does not change that behaviour; it matches it.
**Two records, because one cannot do both jobs.**
| Record | Key | Lifetime | Read by |
|---|---|---|---|
| `reported` | `(language, key_uri, command)` | never cleared, session-scoped | the status-line report only |
| `failures` | `(language, key_uri)` | **cleared when a spawn for that key succeeds** | `*lsp*`, and the per-buffer projection |
**`key_uri` is nil, and Lua cannot index a table by nil.** `t[nil] = v`
raises `table index is nil` — verified by running it under both LuaJIT
and Lua 5.4, not inferred. Since the markerless case *is* the nil case,
the central criterion of §4 acceptance 4 is unimplementable without an
encoding, and leaving it to implementation would let the two tables
diverge on how they spell it.
**One key function, used by both tables:**
```lua
-- Lua strings are 8-bit clean (`#"a\0b" == 3`, checked), so \0 is a
-- separator no path or language id can contain.
--
-- The `u`/`n` discriminator is what makes markerless unambiguous: a
-- bare `key_uri or ""` would collide with a (pathological but legal)
-- empty URI, and a sentinel like "markerless" is a string a URI could
-- in principle equal. The prefix cannot collide with anything.
local function affinity_key(language, key_uri)
return language .. "\0" .. (key_uri and ("u" .. key_uri) or "n")
end
local function reported_key(language, key_uri, command)
return affinity_key(language, key_uri) .. "\0" .. tostring(command)
end
```
`false` would also be a legal Lua key, but the keys here are tuples, so
a single encoded string is what both tables want anyway — and one
function is what stops the two encodings drifting apart.
Rev 1 had one record and said nothing about recovery. One record forces
a bad choice: keep it and `*lsp*` reports a failure the user has since
fixed; clear it and the dedupe is gone, so the message returns on the
next file open.
**`reported` includes the command.** A user who repoints
`pmacs.lsp.config.rust.command` from one missing executable to another
has a genuinely new failure and must hear about it; a key without the
command would swallow it as a duplicate.
**Memoize the report, not the failure.** The spawn is still attempted on
every file open, so a user who installs the binary mid-session gets a
working server with no cache to invalidate — and that success is what
clears `failures`. This asymmetry is the whole rule and the easiest
thing here to get backwards: memoizing the *failure* would be a
behaviour change, would need invalidation, and would make recovery
require a restart.
### 2.3 Where — status line now, `*lsp*` for later
The status line is transient and can be overwritten before it is read;
COHERENCE's rule is that an automatic failure must leave a **trace**,
not a flash. So the same event also lands in `failures`, which
`M-x lsp.status` renders.
**`*lsp*` is opened with `pmacs.listview.open`, not merely "in its
idiom".** Naming the primitive is what buys the guarantees, and a
hand-rolled panel would have to re-derive every one of them:
- **Owned-handle identity.** Found-by-name is *not* adoption
(`listview.lua:157-167`): a foreign buffer already called `*lsp*` is
never claimed, clobbered, or given an erroring intercept.
- **Collision behaviour.** A taken name disambiguates `*lsp*<2>`…`<99>`
and **raises** when exhausted rather than adopting.
- **Immutable generated contents** — a read-only intercept with a named
error, plus round-trip input so a semantic frontend cannot swallow the
panel's single-key bindings.
- **`q`** quits to the previous buffer.
- **`on_refresh`, which is not optional here.** `listview.refresh`
early-returns unless the panel has one (`listview.lua:259`), so
omitting it makes **`g` a dead key** — a bound chord that silently
does nothing. The panel's `on_refresh` recomputes *both* sections, so
`g` after installing a server shows the recovery.
`status_buffer_text()` returns one string; the panel splits it into
inert rows (no `on_visit`) beneath the failure section. Rows without a
visit action are an ordinary listview shape, not a special case.
### 2.4 The durable record lives in Lua, and that is a deliberate limit
§1.6 established that `status_buffer_text` renders from `self.clients`,
which a failed spawn never enters. Two ways to fix that:
- **Record failures in Rust** (`status_tracker`), so `*lsp*` shows them
natively. Correct long-term, but it changes what a "server" is in the
status model, touches typed state several consumers read, and turns a
Lua stage into a Rust one.
- **Record failures in Lua**, in this module, and have `lsp.status`
render them as a section *above* `status_buffer_text()`'s output.
**This stage takes the second**, for the same reason 1b-1 kept its
defaults in a Lua table: it is the smallest thing that makes the failure
visible, and it does not commit the Rust status model to a shape before
anyone has used the surface. The limitation is explicit — `*lsp*`'s
native section still lists only servers that started — and §5 names
promoting it as follow-on work rather than pretending it is done.
### 2.5 The modeline marker
§1.5 is the sharpest half of §1.2: highlighting masks the failure, so a
user has no reason to *go looking* for a command. The segment therefore
gains one branch — when the buffer has a recorded failure and no
attachment, render a distinct label:
```
LSP:! (vs LSP:ok / LSP:… / nothing at all)
```
**The segment must stay a pure per-buffer projection.** Its comment says
so — *"pure modeline projection … reads the private per-buffer
attachment map directly so passive split windows report their own buffer
… never attaches, flushes didChange, or issues a request"*
(`lsp.lua:969-983`) — and the reason is structural, not stylistic: it
runs for **every window, every paint**.
Rev 1 said the segment should "read the failure table", which is keyed
by `(language, key_uri)`. Deriving that key from a buffer means calling
`project_root_for`, which invokes **user-supplied root resolvers** and
`pmacs.project.detect`. Per frame, per window. A resolver with a side
effect, or one that raises, would then run inside painting.
So the failure is **projected per buffer at attach time**, beside the
existing map, and **each projection carries the affinity key that
produced it**:
```lua
failed_attachments[tostring(buf)] = {
key = affinity_key(language, key_uri), -- §2.2
language = …,
command = …,
}
```
**Clearing has to sweep, not touch one entry.** Rev 2 said the two
tables were "written and cleared at the same moment"; that is false as
soon as two buffers share an affinity, which is the normal case for a
project with more than one file:
> A `.rs` file fails, so `failures[K]` and `failed_attachments[A]` are
> both written. The user installs the server and opens a *second* `.rs`
> file in the same project. That spawn succeeds for the same `K`, so
> `failures[K]` is cleared and `*lsp*` reports nothing wrong — while
> **A's modeline still reads `LSP:!`**, because only B's projection was
> touched. The two surfaces now contradict each other, and the stale one
> is the one the user is looking at.
So a success for key `K` clears `failures[K]` **and every projection
whose `key == K`**. The sweep runs on a spawn success, never on a paint.
**The sweep's bound is a consequence of the cleanup below, not a
separate claim.** Rev 3 asserted the table holds "at most the number of
open buffers" while specifying no deletion path, which made the bound
false: a killed buffer's projection would have outlived it, for the
session. `failed_attachments` is bounded by the live buffer set **only
because §2.5.1 removes an entry when its buffer goes away.**
#### 2.5.1 Removal — the entry has to have an owner
`pmacs.buffer.on_removed(buf, fn)` is registered **when a projection is
created**, and its handle is stored in the projection:
```lua
failed_attachments[key] = {
key = affinity_key(language, key_uri),
language = …, command = …,
on_removed = pmacs.buffer.on_removed(buf, function() … end),
}
```
Three rules, each with a reason:
- **Register on creation, never on refresh.** `attach_buffer` is
reachable more than once for the same buffer, so registering per
failed *attempt* would stack callbacks on one buffer — the
unbounded-registrar shape, with the leak simply moved.
- **The removal callback clears the projection and does not call
`handle:remove()`.** Dispatch does `callbacks.take(id)` and then
iterates a local vector (`src/lua_bindings/mod.rs:1949-1957`), so the
entry is already gone; removing from inside would be a no-op at best.
(It is also why this is *not* a mutation-during-iteration hazard — the
registry hands out an owned list before invoking anything.)
- **The success sweep *does* call `handle:remove()`.** There the buffer
is still alive, so an unreleased callback would linger for its whole
lifetime and fire against a projection that no longer exists.
**Nothing existing would have done this for us.** The LSP resource
reconciliation finds work through `attachments_under`
(`lsp.lua:2934-2944`), which iterates `attachments` — and a failed
buffer has no attachment by construction. The projection is invisible to
every current cleanup path, which is exactly why it needs its own.
#### 2.6 Rename and delete — clear, do not re-key
`resource.renamed` and `resource.deleted` already have LSP subscribers
(`lsp.lua:3004`, `:3074`), and they reconcile `attachments`. A failed
buffer is not in that table, so those subscribers must **also** dispose
of its projection.
**The disposition is to clear it.** The projection asserts one thing:
*this buffer's server failed for affinity K*. After a rename, that claim
is no longer known to hold — the new path may sit in a different
project, under a different root, or under none. Re-keying would assert a
failure at a location where none has been observed, which is the same
error shape this arc has been correcting all along: concluding something
about one entity from evidence about another.
Clearing degrades to "we no longer know", which renders as no modeline
marker — the pre-stage behaviour, not a regression. The next attach for
that buffer re-establishes the truth, and if it fails again the report
memo (`reported`, §2.2) correctly treats the *new* affinity as a new
failure worth naming.
Delete is the same, for the stronger reason that the path is gone.
**What must not happen** is the third option: leaving the old-path
projection attached to a buffer whose path has changed, so the modeline
reports `LSP:!` for a location the buffer no longer has. Acceptance 15
and 16 pin the chosen behaviour rather than merely the absence of that
one.
The segment becomes one more map lookup and computes nothing:
```lua
local rec = attachments[key]
if rec then return "LSP:" .. pmacs.lsp.modeline_label(rec.server) end
if failed_attachments[key] then return "LSP:!" end
return nil
```
**It must not fabricate an attachment record.** `attachments` is read by
`attachment_for_request`, the completion driver, and every request path,
all of which treat a record as naming a live server; inventing one would
route requests at a server that does not exist. The failure projection is
a **separate** table for exactly that reason.
Two tables with two readers: `failures` (affinity-keyed, for `*lsp*`)
and `failed_attachments` (buffer-keyed, carrying its affinity key, for
the modeline). Neither can serve the other's reader without doing work
in the wrong place — and, per the sweep above, they are cleared by the
same *event* but not by the same *touch*.
### 2.6 What this stage does not do
- It does not classify errnos into causes (§2.1).
- It does not add a retry, back-off, or auto-install.
- It does not touch `pmacs.error`. Fifteen guarded call sites still
report through a channel that does not exist; building it is its own
lane (§5). This stage adds a sixteenth *only* in the ride-along form
the two existing sites already use, so it upgrades for free and works
today.
- It does not surface `LspEventKind::Crashed`. A server that started and
then died is a different failure with a different message, and no
builtin subscriber handles it today. Named in §5.
## 3. Questions
- **Q#L1 — is per-session the right memo lifetime?** A user who
uninstalls a server mid-session gets no second message. The
alternative — re-report after N minutes — adds a clock to a path that
has none. Recommended: per-session, revisit if it is ever a complaint.
- **Q#L2 — should the message name `init.lua` explicitly?** It assumes
the user has one; a user with no config has nothing to edit. The
counter is that naming the file is what makes the advice actionable,
and `pmacs.config` docs already assume it.
- **Q#L3 — should `lsp.status` get a keybinding?** §20 Priority 4 is
about exactly this class of command, and binding one diagnostic
command ahead of that arc invites the inversion §2 warns about. This
framing says **no binding**, reachable by `M-x`, and lets the
discovery arc bind the family coherently.
- **Q#L4 — does `LSP:!` belong in the modeline, or is it noise for a
user who has deliberately not installed a server?** The case against
§2.5. A user who never wants rust-analyzer sees `!` forever with no
way to dismiss it short of clearing the config.
## 4. Acceptance
Labels per Stage 1a §6.0: **N** new behaviour, must fail on full
revert; **P** preservation, falsified by a named mutation.
**The journey row comes first**, because it is the one that says the
stage worked.
1. **N — journey step 6, end to end** (`tests/journey_acceptance.rs`).
That file's rule is that steps 612 join as later stages make them
real, and 1b-1 added step 9 the same way. Launch on a project whose
configured server command does not exist, open a source file through
the real dired `RET`, and assert the user is **told**: the status
line names the command and the modeline reads `LSP:!` rather than
nothing. This is the ratchet row; the M4-level pins below stay and
cover the mechanism.
2. **N — the failure is reported, through the real path.** The status
line names the command, the language, and the underlying error.
Driven through `buffer.after-load`, not by calling `ensure_server`
directly — the hook's `pcall` is part of what is being tested.
3. **N — reported once per `(language, key_uri, command)`.** Open a
second file **in the same project** (same detected root, so the same
non-nil `key_uri`): the spawn is attempted again — observable, and
asserted — and the message is not repeated. Falsified by dropping the
memo.
4. **N — the markerless case shares one memo, as it shares one server.**
Two loose files in *different* directories, neither under a project
marker, both resolve `key_uri = nil` and report **once** between them.
Falsified by keying the memo on the resolved root, which is rev 1's
design: that reports twice. This pin exists because the root and the
affinity key differ exactly here.
5. **N — a different language, or a genuinely different root, reports
again.** Falsified by keying on the language alone, and by keying on
the language plus a constant.
6. **N — a changed command reports again.** Repoint the config from one
missing executable to another and open a file: a second message,
naming the new command. Falsified by dropping `command` from the
reported identity.
7. **N — the memo is on the report, not the failure.** After a failed
attach, make the command resolvable and open another file: a server
attaches. Falsified by memoizing the failure instead — §2.2's whole
claim, and it needs its own pin.
8. **N — recovery clears both surfaces.** Continuing from 7 in the same
session: the modeline for the recovered buffer reads its live label,
and `M-x lsp.status` no longer lists that failure. Falsified by never
clearing `failures` / `failed_attachments`, which is what one record
would have forced. **Asserted in both surfaces**, because they read
different tables (§2.5).
9. **N — recovery reaches every buffer sharing the affinity.** Buffer A
fails; the command becomes resolvable; buffer B in the **same
project** attaches successfully. Assert **A's** modeline no longer
reads `LSP:!` — not B's. Falsified by clearing only the succeeding
buffer's projection, which is rev 2's design and leaves `*lsp*` and
A's modeline contradicting each other.
*This pin is the reason the projection carries its affinity key at
all*, so it must assert on A: a version that checks B passes on the
broken implementation.
10. **N — `M-x lsp.status` renders a buffer** containing both the
failure section and `status_buffer_text()`'s output. Asserts
**content produced**, not that a buffer exists.
11. **N — `g` refreshes the panel.** With the panel open: make the
command resolvable **and then reattach** — open a file in the
project so a spawn actually succeeds — then press `g`, and the
failure section is gone. **The reattach is load-bearing**: making
the command resolvable changes no state by itself, since `failures`
is cleared by a successful spawn, so a version of this pin that
only edits the config and presses `g` would assert nothing about
refresh. Falsified by omitting `on_refresh`, which makes
`listview.refresh` early-return (`listview.lua:259`) and leaves the
panel stale while `g` appears bound.
12. **N — a foreign `*lsp*` buffer is not adopted.** Create a buffer
named `*lsp*` with user content, then run `lsp.status`: the user's
bytes are untouched and the panel opens as `*lsp*<2>`. This is
`listview.open`'s guarantee, and it is pinned here rather than
assumed because "found by name is not adoption" is precisely the
rule a hand-rolled panel loses.
13. **N — the modeline distinguishes failed from not-applicable.** A
source file with a failed spawn renders `LSP:!`; a plain-text buffer
renders nothing. **Both halves asserted** — a pin that checks only
the failing case passes if the segment renders `!` unconditionally.
14. **N — a killed buffer's projection is removed.** Fail an attach,
then kill the buffer; the projection is gone. Observable without
reaching into module internals: open a *new* buffer for the same
path, and assert the sweep on a later success still terminates and
that no `LSP:!` is attributed to a buffer that never failed.
Falsified by omitting the `on_removed` registration, which is rev
3's state — the entry then outlives its buffer for the session.
*This is the pin that makes §2.5's bound true rather than asserted;*
without cleanup the table is bounded by nothing.
15. **N — a rename clears the projection.** Fail an attach, then rename
the file through the real `resource.renamed` path. The modeline no
longer reads `LSP:!` for that buffer. Falsified by leaving the
projection keyed to the old affinity, which would report a failure
for a path the buffer no longer has.
16. **N — a delete clears the projection.** Same shape through
`resource.deleted`. Falsified the same way.
*Both 15 and 16 assert the chosen behaviour, not merely the absence
of the forbidden one:* a pin that only checked "does not show a
stale path" would pass on an implementation that cleared nothing and
happened to render nothing for an unrelated reason.
17. **P — the segment does no work.** With a failure recorded, painting
the modeline invokes neither a root resolver nor
`pmacs.project.detect`. Pinned with a counting resolver installed
through the real config; assert the count is unchanged across
repeated renders. Targeted mutation: rev 1's design, which derives
the affinity key inside the provider.
18. **P — a working server is unaffected.** Attach, modeline label,
requests: unchanged. Targeted mutation: making the new branch fire
whenever an attachment is absent, which would mark every plain-text
buffer failed.
19. **P — the two existing report sites still report.** Root-resolver
and subscriber failures keep their messages. Targeted mutation:
refactoring the three sites onto a shared helper that drops one.
20. **P — a fabricated attachment is never created.** After a failed
spawn, `attachment_for_request` returns nil and no request is
issued. Targeted mutation: §2.5's forbidden implementation.
**Fixture note.** The natural fixture points a config at a path that
does not exist, which is reliable and hermetic. It must assert its own
precondition — that the command really is absent — because a fixture
that accidentally names a real binary would make every absence
assertion vacuous. That is the shape that bit both #202's in-drain pin
and 1b-1's nested-project pin.
**Ambient-root note.** These tests construct an editor, so they need the
five bootstrap-storage variables controlled locally (#201 is framing
only). A developer with a real `rust-analyzer` on PATH must not change
the result — hence a configured non-existent command rather than
relying on rust-analyzer's absence.
## 5. Deferred, named rather than implied
- **Building `pmacs.error`.** Fifteen dead call sites; its own lane.
- **Promoting the failure record into Rust's status model**, so `*lsp*`
shows failed spawns natively (§2.4).
- **Surfacing `LspEventKind::Crashed`** — a started-then-died server
(§2.6).
- **A keybinding for `lsp.status`**, which belongs to §20 Priority 4's
discovery family (Q#L3).
- **Journey Stage 1b-3**, the welcome buffer (step 4).
## 6. Coherence impact
- **Journey steps touched:** 6 (Partial → Works for the failure case;
the success case is already fine). Indirectly 10, which is gated on 6
or 9 succeeding. 9 was closed by 1b-1. **The ratchet gains a step-6
row** (§4 acceptance 1) — `tests/journey_acceptance.rs` states that
steps 612 join as later stages make them real, so a stage that makes
one real and adds no row leaves the ratchet describing a journey the
editor has outgrown.
- **Interaction islands: none added.** The message uses the existing
status line; `*lsp*` is opened through **`pmacs.listview.open`**
(§2.3), the primitive `COHERENCE.md` §14 records as the proven
listview/panel shape — not a third hand-rolled read-only buffer. §14's
complaint is precisely that each new panel re-invented ownership,
read-only enforcement, and quit behaviour; naming the primitive is how
this stage avoids being the next example.
- **Config registry:** not adopted. Nothing here is a user-tunable
scalar; the memo is session state, not configuration.
- **Background-work attribution:** this *is* the attribution fix for one
failure — §1.2's rule that anything failing automatically must leave a
user-visible trace with a named owner.
- **Doc updates riding this PR:** `COHERENCE.md` §1.2's stale citations
and frequency note (§1.7), its step-6 verdict row, §20 Priority 1's
remainder line, §24; `docs/keybindings.md` if Q#L3 is overturned;
`docs/agent-handoff.md` §1; the ledger.
## 7. Ledger
Branch `journey-stage1b2-lsp-guidance`, worktree `../pmacs-journey-1b2`,
based on `githubsucks/main` @ `fbcf235`. Framing only; no code, no PR.
Recovery from a clean checkout — the two-argument form of
`git worktree add` does not work for a remote-only branch:
```sh
git fetch githubsucks
git worktree add ../pmacs-journey-1b2 \
-b journey-stage1b2-lsp-guidance \
githubsucks/journey-stage1b2-lsp-guidance
```

View File

@ -189,6 +189,13 @@ Source: `builtin/runtime/compile.lua`.
| `M-!` | `shell.command` — asynchronous output in `*shell-command*` |
| `C-c c` | `compile.run` — prompts, prefilled from the detected project kind |
`M-x help` renders this file's essentials as a `*help*` buffer inside
the editor, and is what the startup welcome points at. It is the root of
the eventual help family (`help.keys` and friends arrive with the
discovery arc), so it takes no keybinding yet — `C-h` is **not** free:
it deletes a word because non-kitty terminals cannot tell Ctrl+Backspace
from Ctrl+H.
`compile.recompile` is available through `M-x`, and through `g` inside
`*compilation*`; no global key is assigned to it. `C-c c` is unreachable
from inside a terminal window (`C-c` is consumed as the escape key) and

View File

@ -2038,3 +2038,89 @@ fn journey_step4_preservation_constructors_never_greet() {
"EditorState::open(dir) must not greet"
);
}
// Step 6 — receive language intelligence (Journey Stage 1b-2)
//
// `COHERENCE.md` §2 graded this **Partial**: a preconfigured server that
// is not installed failed silently, and working tree-sitter highlighting
// masked it. This row pins the failure being *told*, end to end, through
// the same walk a user takes.
// ---------------------------------------------------------------------------
/// The `lsp` modeline segment for the active buffer, found by face —
/// `EvaluatedStatuslineSegment` carries `provider_id`, not the
/// registration's name.
fn lsp_segment(s: &EditorState) -> Option<String> {
let outcome = pmacs::statusline::evaluate_statusline(
s.lua_host.lua(),
&s.core,
&s.statusline_registry,
pmacs::statusline::StatuslineEvaluationTarget::Grid {
frontend_id: FrontendId::LOCAL,
},
);
let pmacs::statusline::StatuslineEvaluationOutcome::Ready(windows) = outcome.outcome else {
return None;
};
windows
.into_iter()
.flat_map(|w| w.right)
.find(|seg| seg.face == "ui.modeline.lsp")
.map(|seg| seg.text)
}
/// **N** — step 6: when language intelligence cannot start, the user is
/// told, on the path `pmacs .` actually takes.
///
/// The configured command is one that cannot exist, so a developer with
/// `rust-analyzer` installed gets the same result as CI — and the
/// fixture asserts that precondition, or every assertion here would be
/// vacuous.
#[test]
fn journey_step6_a_missing_language_server_is_reported_not_swallowed() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("Cargo.toml"), b"[package]\nname=\"x\"\n").expect("write toml");
std::fs::write(td.path().join("main.rs"), b"fn main() {}\n").expect("write rs");
let absent = td.path().join("no-such-bin").join("rust-analyzer");
assert!(
!absent.exists(),
"fixture precondition: the configured server must not exist"
);
// Launch as `pmacs .` does — this lists the directory in dired.
let mut s = launch(td.path());
// `launch` clears `pmacs.lsp.config`, so configure after it.
exec(
&s,
&format!(
"pmacs.project.set_search_boundary({:?})
pmacs.lsp.config.rust = {{ command = {:?} }}",
td.path().display().to_string(),
absent.display().to_string()
),
);
// Visit the source file with the real key, as step 5 does.
let line = line_of(&s, "main.rs");
exec(&s, &format!("pmacs.editor.move_to_line({line})"));
press(&mut s, KeyCode::Enter);
pump(&mut s);
assert_eq!(
active_name(&s),
td.path().join("main.rs").display().to_string(),
"precondition: the walk must actually open the source file"
);
let msg = status(&s);
assert!(
msg.contains(&absent.display().to_string()),
"the user is told which command did not start; got {msg:?}"
);
assert_eq!(
lsp_segment(&s).as_deref(),
Some("LSP:!"),
"and the modeline says so, rather than rendering nothing — which \
is what made highlighting able to mask this"
);
}

View File

@ -0,0 +1,567 @@
// tests/lsp_spawn_guidance_acceptance.rs --- journey Stage 1b-2.
//! `COHERENCE.md` §1.2's canonical silence: a preconfigured language
//! server that is not installed used to fail with no status message, no
//! record, and no modeline marker, while tree-sitter highlighting kept
//! working and masked it.
//!
//! Pins are labelled **N** (new behaviour, must fail on full revert) or
//! **P** (preservation, falsified by a named targeted mutation), per
//! `docs/journey-stage1b2-lsp-guidance-framing.md` §4.
//!
//! **Every fixture points the config at a path that does not exist**,
//! rather than relying on a real server's absence — a developer with
//! `rust-analyzer` installed must get the same result as CI. Each
//! fixture asserts that precondition, because a fixture that
//! accidentally named a real binary would make every absence assertion
//! here vacuous.
use std::path::{Path, PathBuf};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
use pmacs::statusline::{
StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline,
};
use tempfile::TempDir;
fn exec(state: &EditorState, source: &str) {
state.lua_host.lua().load(source.to_owned()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(state: &EditorState, source: &str) -> T {
state.lua_host.lua().load(source.to_owned()).eval().unwrap()
}
fn status(state: &EditorState) -> String {
state.core.borrow().status.clone()
}
fn lua_str(s: &str) -> String {
format!("{s:?}")
}
/// A command path that cannot exist. Asserted, not assumed: if this ever
/// resolved, every "did not start" assertion below would be vacuous.
fn absent_command(dir: &Path, name: &str) -> String {
let p = dir.join("no-such-bin").join(name);
assert!(
!p.exists(),
"fixture precondition: {} must not exist",
p.display()
);
p.display().to_string()
}
/// Point the default `rust` server at `command`.
fn configure_rust(state: &EditorState, command: &str) {
exec(
state,
&format!(
"pmacs.lsp.config.rust = {{ command = {} }}",
lua_str(command)
),
);
}
/// A Cargo project, so `key_uri` is non-nil (root detected by marker).
fn cargo_project() -> TempDir {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("Cargo.toml"), b"[package]\nname=\"x\"\n").expect("write");
td
}
fn write_rs(dir: &Path, name: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, b"fn main() {}\n").expect("write rs");
p
}
/// Open a file the way `buffer.after-load` sees it.
fn open(state: &EditorState, path: &Path) {
exec(
state,
&format!(
"pmacs.buffer.find_or_open({})",
lua_str(&path.display().to_string())
),
);
}
fn editor_for(dir: &Path) -> EditorState {
let state = EditorState::new();
// Clamp detection so a stray marker above the tempdir cannot leak in.
exec(
&state,
&format!(
"pmacs.project.set_search_boundary({})",
lua_str(&dir.display().to_string())
),
);
state
}
/// The `lsp` modeline segment's text for the active buffer, or `None`.
fn lsp_segment(state: &EditorState) -> Option<String> {
let outcome = evaluate_statusline(
state.lua_host.lua(),
&state.core,
&state.statusline_registry,
StatuslineEvaluationTarget::Grid {
frontend_id: FrontendId::LOCAL,
},
);
let StatuslineEvaluationOutcome::Ready(windows) = outcome.outcome else {
return None;
};
// Found by face rather than by name: `EvaluatedStatuslineSegment`
// carries `provider_id`, not the registration's name, and the face
// is the stable public identity of this segment.
windows
.into_iter()
.flat_map(|w| w.right)
.find(|s| s.face == "ui.modeline.lsp")
.map(|s| s.text)
}
fn clear_status(state: &EditorState) {
exec(state, "pmacs.editor.set_status('')");
}
fn failure_count(state: &EditorState) -> i64 {
eval(state, "return #pmacs.lsp.spawn_failures()")
}
// ---------------------------------------------------------------------------
// Reporting
// ---------------------------------------------------------------------------
/// **N** (framing acceptance 2) — the failure is reported, through the
/// real `buffer.after-load` path, naming the command, the language and
/// the underlying error.
#[test]
fn j1b2_a_missing_server_is_reported_with_guidance() {
let td = cargo_project();
let state = editor_for(td.path());
let cmd = absent_command(td.path(), "rust-analyzer");
configure_rust(&state, &cmd);
open(&state, &write_rs(td.path(), "a.rs"));
let msg = status(&state);
assert!(msg.contains(&cmd), "names the command; got {msg:?}");
assert!(msg.contains("rust"), "names the language; got {msg:?}");
assert!(
msg.contains("No such file") || msg.contains("os error 2"),
"passes the underlying error through; got {msg:?}"
);
assert!(
msg.contains("init.lua"),
"says what the user can do; got {msg:?}"
);
}
/// **N** (3) — reported once per `(language, key_uri, command)`. The
/// spawn is still attempted on the second open; only the message is
/// suppressed.
#[test]
fn j1b2_a_repeat_failure_in_the_same_project_is_not_reannounced() {
let td = cargo_project();
let state = editor_for(td.path());
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
open(&state, &write_rs(td.path(), "a.rs"));
assert!(!status(&state).is_empty(), "first open reports");
clear_status(&state);
open(&state, &write_rs(td.path(), "b.rs"));
assert_eq!(
status(&state),
"",
"a second file in the same project must not re-announce"
);
// The failure is still current — the memo is on the report, not the
// failure — so the surface still knows about it.
assert_eq!(failure_count(&state), 1);
}
/// **N** (4) — the markerless case shares one memo, because it shares
/// one server. Two loose files in *different* directories both resolve
/// `key_uri = nil`.
///
/// Falsified by keying the memo on the resolved root, which reports
/// twice. This is the pin where the root and the affinity key differ.
#[test]
fn j1b2_markerless_files_in_different_directories_report_once() {
let td = tempfile::tempdir().expect("tempdir");
let one = td.path().join("one");
let two = td.path().join("two");
std::fs::create_dir_all(&one).expect("mkdir one");
std::fs::create_dir_all(&two).expect("mkdir two");
let state = editor_for(td.path());
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
open(&state, &write_rs(&one, "a.rs"));
assert!(!status(&state).is_empty(), "first markerless open reports");
clear_status(&state);
open(&state, &write_rs(&two, "b.rs"));
assert_eq!(
status(&state),
"",
"a markerless file elsewhere shares the same (language, nil) key"
);
assert_eq!(
failure_count(&state),
1,
"and therefore one failure, not two"
);
}
/// **N** (5) — a genuinely different root reports again.
#[test]
fn j1b2_a_different_project_root_reports_again() {
let outer = tempfile::tempdir().expect("tempdir");
let a = outer.path().join("a");
let b = outer.path().join("b");
std::fs::create_dir_all(&a).expect("mkdir a");
std::fs::create_dir_all(&b).expect("mkdir b");
std::fs::write(a.join("Cargo.toml"), b"[package]\nname=\"a\"\n").expect("w");
std::fs::write(b.join("Cargo.toml"), b"[package]\nname=\"b\"\n").expect("w");
let state = editor_for(outer.path());
configure_rust(&state, &absent_command(outer.path(), "rust-analyzer"));
open(&state, &write_rs(&a, "a.rs"));
clear_status(&state);
open(&state, &write_rs(&b, "b.rs"));
assert!(
!status(&state).is_empty(),
"a different detected root is a different affinity"
);
assert_eq!(failure_count(&state), 2);
}
/// **N** (6) — a changed command reports again, because the reported
/// identity includes it.
#[test]
fn j1b2_repointing_at_another_missing_command_reports_again() {
let td = cargo_project();
let state = editor_for(td.path());
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
open(&state, &write_rs(td.path(), "a.rs"));
clear_status(&state);
let second = absent_command(td.path(), "rust-analyzer-2");
configure_rust(&state, &second);
open(&state, &write_rs(td.path(), "b.rs"));
let msg = status(&state);
assert!(
msg.contains(&second),
"a different missing executable is a new failure; got {msg:?}"
);
}
// ---------------------------------------------------------------------------
// Recovery
// ---------------------------------------------------------------------------
/// **N** (7, 8) — the memo is on the report, not the failure: the spawn
/// is retried, so a resolvable command recovers with nothing to
/// invalidate, and both surfaces go quiet.
#[test]
fn j1b2_recovery_clears_the_failure_surface() {
let td = cargo_project();
let state = editor_for(td.path());
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
open(&state, &write_rs(td.path(), "a.rs"));
assert_eq!(failure_count(&state), 1, "precondition: a failure exists");
// `/bin/sh` exists and is spawnable; it is not an LSP server, but
// this pin is about the spawn succeeding, not about initialize.
configure_rust(&state, "/bin/sh");
open(&state, &write_rs(td.path(), "b.rs"));
assert_eq!(
failure_count(&state),
0,
"a successful spawn for the same affinity clears the failure"
);
}
/// **N** (9) — recovery reaches **every** buffer sharing the affinity,
/// not just the one that succeeded.
///
/// Asserted on A, deliberately: a version of this pin that checked B
/// passes on the broken implementation, where only the succeeding
/// buffer's projection is cleared and A keeps rendering `LSP:!` while
/// `lsp.status` reports nothing wrong.
#[test]
fn j1b2_recovery_reaches_every_buffer_sharing_the_affinity() {
let td = cargo_project();
let state = editor_for(td.path());
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
let a = write_rs(td.path(), "a.rs");
open(&state, &a);
assert_eq!(
lsp_segment(&state).as_deref(),
Some("LSP:!"),
"precondition: A is marked failed"
);
configure_rust(&state, "/bin/sh");
open(&state, &write_rs(td.path(), "b.rs"));
// Back to A — the buffer that never succeeded itself.
open(&state, &a);
assert_ne!(
lsp_segment(&state).as_deref(),
Some("LSP:!"),
"A must stop claiming a failure that the shared affinity has resolved"
);
}
// ---------------------------------------------------------------------------
// Modeline
// ---------------------------------------------------------------------------
/// **N** (13) — the modeline distinguishes "failed" from "not
/// applicable". Both halves asserted: a pin checking only the failing
/// case passes if the segment renders `!` unconditionally.
#[test]
fn j1b2_the_modeline_distinguishes_failed_from_unsupported() {
let td = cargo_project();
let state = editor_for(td.path());
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
open(&state, &write_rs(td.path(), "a.rs"));
assert_eq!(
lsp_segment(&state).as_deref(),
Some("LSP:!"),
"a source file whose server failed says so"
);
let txt = td.path().join("notes.txt");
std::fs::write(&txt, b"plain\n").expect("write txt");
open(&state, &txt);
assert_eq!(
lsp_segment(&state),
None,
"a file with no configured server renders nothing at all"
);
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/// **N** (14) — a killed buffer's projection is removed.
///
/// Without the `pmacs.buffer.on_removed` registration the entry outlives
/// its buffer for the session, and nothing else can reach it: the LSP
/// resource reconciliation iterates `attachments`, and a failed buffer
/// has none by construction.
#[test]
fn j1b2_a_killed_buffer_drops_its_failure_projection() {
let td = cargo_project();
let state = editor_for(td.path());
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
let a = write_rs(td.path(), "a.rs");
open(&state, &a);
assert_eq!(lsp_segment(&state).as_deref(), Some("LSP:!"));
let before: i64 = eval(&state, "return #pmacs.buffer.list()");
exec(&state, "pmacs.buffer.remove(pmacs.window.buffer())");
let after: i64 = eval(&state, "return #pmacs.buffer.list()");
assert!(
after < before,
"precondition: the buffer really was removed"
);
// Re-open the same path: a fresh buffer that has never failed must
// not inherit a marker, and the stale projection must not be what
// answers for it.
open(&state, &a);
configure_rust(&state, "/bin/sh");
open(&state, &write_rs(td.path(), "b.rs"));
assert_eq!(
failure_count(&state),
0,
"the sweep still terminates and clears with a killed buffer in play"
);
}
/// **N** (15) — a rename clears the projection rather than leaving an
/// old-path failure attached to a changed buffer.
#[test]
fn j1b2_a_rename_clears_the_failure_projection() {
let td = cargo_project();
let state = editor_for(td.path());
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
let a = write_rs(td.path(), "a.rs");
open(&state, &a);
assert_eq!(lsp_segment(&state).as_deref(), Some("LSP:!"));
let renamed = td.path().join("renamed.rs");
exec(
&state,
&format!(
"pmacs.hook.run('resource.renamed', {}, {})",
lua_str(&a.display().to_string()),
lua_str(&renamed.display().to_string())
),
);
assert_ne!(
lsp_segment(&state).as_deref(),
Some("LSP:!"),
"after a rename the projection no longer describes this buffer"
);
}
/// **N** (16) — a delete clears it too.
#[test]
fn j1b2_a_delete_clears_the_failure_projection() {
let td = cargo_project();
let state = editor_for(td.path());
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
let a = write_rs(td.path(), "a.rs");
open(&state, &a);
assert_eq!(lsp_segment(&state).as_deref(), Some("LSP:!"));
exec(
&state,
&format!(
"pmacs.hook.run('resource.deleted', {})",
lua_str(&a.display().to_string())
),
);
assert_ne!(
lsp_segment(&state).as_deref(),
Some("LSP:!"),
"a deleted path leaves no failure to project"
);
}
// ---------------------------------------------------------------------------
// The *lsp* panel
// ---------------------------------------------------------------------------
fn named_text(state: &EditorState, name: &str) -> String {
eval(
state,
&format!(
r#"
for _, id in ipairs(pmacs.buffer.list()) do
if pmacs.describe.buffer(id).name == {name:?} then
return id:slice(0, id:len())
end
end
return ""
"#
),
)
}
/// **N** (10) — `M-x lsp.status` renders both sections. Asserts content
/// produced, not that a buffer exists.
#[test]
fn j1b2_lsp_status_renders_failures_and_servers() {
let td = cargo_project();
let state = editor_for(td.path());
let cmd = absent_command(td.path(), "rust-analyzer");
configure_rust(&state, &cmd);
open(&state, &write_rs(td.path(), "a.rs"));
exec(&state, "pmacs.command.invoke('lsp.status')");
let text = named_text(&state, "*lsp*");
assert!(
text.contains(&cmd),
"the failure section names the command; got:\n{text}"
);
assert!(
text.contains("Servers:"),
"and `status_buffer_text` still renders beneath it; got:\n{text}"
);
}
/// **N** (11) — `g` refreshes. The **reattach is load-bearing**: making
/// the command resolvable changes no state on its own, since `failures`
/// is cleared by a successful spawn.
#[test]
fn j1b2_g_refreshes_the_lsp_panel_after_recovery() {
let td = cargo_project();
let state = editor_for(td.path());
let cmd = absent_command(td.path(), "rust-analyzer");
configure_rust(&state, &cmd);
open(&state, &write_rs(td.path(), "a.rs"));
exec(&state, "pmacs.command.invoke('lsp.status')");
assert!(named_text(&state, "*lsp*").contains(&cmd));
// Resolve AND reattach, then refresh in place.
configure_rust(&state, "/bin/sh");
open(&state, &write_rs(td.path(), "b.rs"));
exec(&state, "pmacs.command.invoke('lsp.status')");
exec(&state, "pmacs.command.invoke('listview.refresh')");
assert!(
!named_text(&state, "*lsp*").contains(&cmd),
"g must re-render, not leave the panel stale"
);
}
/// **N** (12) — a foreign `*lsp*` buffer is never adopted. This is
/// `listview.open`'s guarantee, pinned rather than assumed because it is
/// exactly what a hand-rolled panel loses.
#[test]
fn j1b2_a_foreign_lsp_buffer_is_not_adopted() {
let td = cargo_project();
let state = editor_for(td.path());
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
exec(
&state,
"local b = pmacs.buffer.create('*lsp*') b:insert(0, 'user bytes')",
);
exec(&state, "pmacs.command.invoke('lsp.status')");
assert_eq!(
named_text(&state, "*lsp*"),
"user bytes",
"the user's buffer is untouched"
);
assert!(
named_text(&state, "*lsp*<2>").contains("Servers:"),
"and the panel opens beside it"
);
}
// ---------------------------------------------------------------------------
// Preservation
// ---------------------------------------------------------------------------
/// **P** (20) — a failed spawn never fabricates an attachment record.
/// Targeted mutation: recording the failure in `attachments`, which
/// would route requests at a server that does not exist.
#[test]
fn j1b2_preservation_a_failed_spawn_creates_no_attachment() {
let td = cargo_project();
let state = editor_for(td.path());
configure_rust(&state, &absent_command(td.path(), "rust-analyzer"));
open(&state, &write_rs(td.path(), "a.rs"));
assert!(
eval::<bool>(&state, "return pmacs.lsp.attachment_for_request() == nil"),
"no request may be issued against a server that failed to start"
);
}
/// **P** (18) — a spawnable server is unaffected: it attaches, and the
/// modeline reports the server rather than a failure.
#[test]
fn j1b2_preservation_a_spawnable_server_still_attaches() {
let td = cargo_project();
let state = editor_for(td.path());
configure_rust(&state, "/bin/sh");
open(&state, &write_rs(td.path(), "a.rs"));
let seg = lsp_segment(&state);
assert!(
seg.is_some() && seg.as_deref() != Some("LSP:!"),
"a started server keeps its own label; got {seg:?}"
);
assert_eq!(failure_count(&state), 0);
}