Commit Graph

7 Commits

Author SHA1 Message Date
Levi Neuwirth 41d37fcbcb
refactor(panel): one shared rule for the adopter `display` vocabulary
Stage 3 step 2 (Q#S3-1). DEFAULT-PRESERVING WITH ONE INTENTIONAL
NORMALIZATION — not "behaviour-preserving", which would be too broad a
claim. Every adopter keeps its current default, and the full serialized
suite is 3447 passed / 0 failed with ZERO suites differing from the
pre-change baseline. But invalid-input behaviour DID move, deliberately,
and that is pinned rather than asserted in prose.

Before this, FOUR adopters validated the same three-value vocabulary in
four places: Rust for the terminal, and hand-written Lua copies in
listview.lua, compile.lua and dired.lua, each carrying its own copy of
the error string. `parse_adopter_placement` read like the shared parser
its doc comment implied but had exactly one caller. Four copies of one
rule is how the next adopter gets it subtly wrong, and the next adopter
is DAP.

`resolve_adopter_display(operation, raw, default)` now owns exactly
three things: the vocabulary, the error text, and the default policy.
Reachable from Lua as the internal seam `pmacs.window._resolve_display`.

THE DEFAULT IS A PARAMETER, NOT A CONSTANT, and that is load-bearing
rather than stylistic. listview/compile/terminal will resolve omission
to the panel in step 3; DIRED MUST NOT, because
`pmacs.path.set_directory_handler` calls it with `{ dest = dest }` and
no `display` key at all — a flipped default would open `pmacs .` in a
bottom panel. Passing the default in makes dired's exemption visible at
its call site instead of hidden in a divergent copy.

TERMINAL'S `window` MUTUAL-EXCLUSION STAYS IN ITS OWN WRAPPER. Only the
terminal accepts a `window` id and only it must reject `window` combined
with `display = "panel"`. A helper pretending the four parsers were
identical would be its own defect.

THE NORMALIZATION, DECIDED AND PINNED. Terminal read
`get::<Option<String>>("display")?`, so a non-string value raised mlua's
TYPE error before reaching any custom message, while the Lua copies
stringified it into their own. Nothing pinned either behaviour — every
existing assertion passes an unknown STRING, which takes the same path
under both designs and therefore could not have caught a regression
here. The custom error wins because it names the legal vocabulary; the
value is rendered by TYPE ALONE (`unknown display (integer)`) so the
message cannot imply a string was passed.

Pinned at the terminal entry point in acc19 — the one adopter whose
behaviour changed — asserting the shared error AND that nothing is
created. The type SPELLING is deliberately not pinned: Lua 5.4 says
`integer` where LuaJIT has no integer subtype, so asserting either
literal would pass on one CI flavor and fail on the other. Verified
46/46 under both.

COMPILE NEEDED AN EXPLICIT OMISSION CAPTURE, and finding that out is
what this step is for. The resolver collapses omission into its default,
but compile's recompile gate distinguishes them: it fires on OMISSION
only, never on an explicit `display = "current"`, which is the
documented opt-out and must reach the raw switch even when the previous
run was panel-placed. Resolving first and testing `== "current"`
afterwards would have silently merged the two and broken the opt-out
with every test still green. `display_omitted` is captured before the
resolver call and the gate keys on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:25:27 -04:00
Levi Neuwirth e1b859fd4f feat(generated-buffers): dired and listview adopt the authorized write
Stage 1 of generated-buffer immutability
(docs/generated-buffer-immutability-framing.md, revision 5). Closes the
two families the bug is reachable on WITHOUT `M-x`: `compile.lua` and the
search panel rebind all seven undo chords to a no-op, but `dired.lua` and
`listview.lua` rebind nothing, so a bare `C-/` emptied a listing and a
panel. The cheap half is also the exposed half.

An intercept is not read-only. `Buffer::undo` reaches the rope through
`ensure_writable` and never consults the intercept chain, so the
erroring-intercept-plus-`bypass_intercept`-over-a-writable-rope idiom
guarded the edit path and left the history path open. Rebinding chords
does not close it: `M-x buffer.undo` is dispatchable on every buffer in
the tree.

- `dired.lua`'s `paint` and `listview.lua`'s `render` write through
  `pmacs.buffer.set_generated_contents` — lift the lock, whole-buffer
  replace skipping intercepts, discard history, re-assert the lock, fan
  the `Edit` out. Zero `bypass_intercept` writes remain in either file.
- Both keep their named erroring intercept and `set_round_trip_input`.
  The layering at `terminal.lua:351-366` is unchanged: the rope lock
  protects the daemon copy, round-trip input protects a semantic
  frontend's own mirror, and neither substitutes for the other.
- Q#GB13 — `listview.ensure_panel` stops adopting a same-named foreign
  buffer. Ownership is the `panels` table; a collision disambiguates
  `<2>`..`<99>` and raises at the limit, matching `dired.lua:476-504`.
  This is a prerequisite of the lock, not a follow-up: the arc removes
  the `M-x buffer.undo` that was the only recovery from a clobber.
- Q#GB18 — `panels` becomes a compacting list keyed by identity. It was
  written under the requested name and read back under the actual name,
  which a disambiguated panel breaks: `RET`, `g` and `q` fail closed and
  silently, and `listview.open`'s capture guard fails OPEN, capturing a
  panel as its own `q` target — the chained-panel loop its comment says
  it prevents. Ships in the same commit as the disambiguation by the
  framing's ordering constraint.
- Q#GB6 — `EditorCore::notify_buffer_edit` clamps each window coordinate
  against its own post-edit bound, unconditionally. `cursor` is a byte
  position bounded by `Buffer::len`; `view_top` is a line index bounded
  by `TextView::line_count`, and a replace can grow in bytes while
  collapsing lines, so "the buffer shrank" is not a usable trigger. This
  fixes a shipped defect that reaches terminal copy mode.
- Q#GB16(a) — locking these families disables fold CREATION on them,
  because `document_bytes` is spelled `is_read_only()`. Accepted and
  stated rather than shipped silently; the status string now names the
  read-only lock instead of claiming "not a document buffer".

Acceptance: 10 new criteria in `listview_acceptance` (16 total), 6 in
`dired_acceptance` (31 total), 2 in `terminal_copy_mode_acceptance`.
Every criterion's falsifying mutation was run: 5 bite by revert against
`githubsucks/main`, 9 by a named one-line mutation.

Two framing corrections, both recorded in the tests rather than worked
around silently:

- Stage 1 criterion 5 is unreachable as written. `Buffer::apply_edit`
  (`src/buffer.rs:773`) and `begin_edit` (`:725`) call `ensure_writable`
  as their FIRST statement while the intercept chain runs later inside
  `apply_edit_inner` (`:1072`), so once this arc's lock is installed an
  ordinary edit can never reach the intercept. Restated at the one point
  where the two are distinguishable — the lock lifted — which is the
  state the intercept genuinely still covers.
- Criterion 7 cannot bite at the listview adopter. `listview.refresh`
  and `listview.open` both follow `render` with `window.switch_buffer`,
  which rebuilds the `TextView` from scratch and masks a dropped
  fan-out. `dired.revert` does not, so the dired half carries the bite;
  it fails under the mutation with the reported
  `assertion failed: end <= self.len()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
2026-07-29 09:50:09 -04:00
Levi Neuwirth 7741cf806a fix(journey): honor the captured window, not the selected one
Review round 1 of PR #182. One implementation gap and two stale claims.

**The scope pins the frontend; it does not pin the window.** Framing
§4.4 specified `display{ window = dest:window() }`, but dired's commit
still ended in `pmacs.window.switch_buffer`, which targets whatever
window the scoped frontend has selected. A split or panel that took
focus while `read_dir` was pending therefore received the listing, and
`prev` was captured from it too — with every preflight check passing,
because the captured window was still live and still held its captured
buffer. Both sites now read the captured window: `display` routes to it
with `select = true` (the later `seat_cursor` acts on the active
window), and the `prev` read asks it directly.

N4c pins both halves. The suite's existing routing pins all varied
*frontend* identity; none varied the selected window within one
frontend, which is exactly why 23 green pins missed this. Bite: dired's
`display` back to `switch_buffer` fails N4c alone; `prev` read from the
ambient window fails N4c alone.

Two stale documentation claims, both of which this PR was supposed to
have already fixed:

* **The §0 scorecard still graded §2 "Broken at entry"** while §2's own
  ground truth had been rewritten. The scorecard is a second copy of the
  same claim and §25's protocol covers both. §19's row and ground truth
  were stale the same way — this PR creates the first cross-subsystem
  suite, which §19 says should exist and grades as missing — and are
  corrected too.
* **P4 still read "leaves exactly one buffer"**, the exact claim rev 6
  corrected as false everywhere else in the framing. Restated to what it
  actually pins: the file is in the *active window*. The test was
  already written correctly; only the framing lied.

Framing rev 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:20:02 -04:00
Levi Neuwirth f09f66ce37 feat(journey): open a directory, on one path
Journey Stage 1a's core: `pmacs .` opens the directory instead of
exiting 1, and local startup stops being a second implementation of
path resolution.

`EditorState::open` now calls `EditorCore::resolve_target_buffer` --
the primitive whose own doc comment says it exists "so two
path-normalization, dedup, and hook transactions cannot drift apart",
and which local startup had never been a caller of.

`resolve_target_buffer` returns a typed `ResolvedTarget` rather than
`(BufferId, HookKind)`, with a `Directory` arm checked ahead of the
load. Without it the load runs and fails: `File::open` succeeds on a
directory and `read_to_end` returns EISDIR, which is not `NotFound`, so
the `[new file]` arm never fired.

A directory creates no buffer. It dispatches a resolver chain: the
short-circuit `path.open-directory` hook, which no builtin subscribes
to, and then `pmacs.path.directory_handler`, which dired defaults. The
split is forced rather than chosen -- hook callbacks only append and
builtins load before init.lua, so a subscribing builtin would always
claim before any user listener could run. A raising listener stops the
chain and suppresses the fallback.

The listing is async and the daemon bootstrap is not, so the whole
post-await commit runs inside a new `pmacs.window.commit_to`: it
validates the destination -- frontend live, window live, buffer
unchanged, window replaceable -- BEFORE invoking its callback, then
scopes the acting frontend for its extent. Validating at display time
would be four dired mutations too late.

That scope is deliberately not `InteractiveCommandOrigin`, which does
not reach the core-ambient APIs and is authenticated user-command
authority a background continuation must not acquire.

The dedication rule is extracted into one `window_accepts_buffer`
shared by exact display, the display probe, and the new preflight, with
`incoming: Option<BufferId>` -- `None` means "the replacement does not
exist yet" and refuses a dedicated window.

`display_file` keeps its directory-is-an-error contract and does not
enter the chain; find-file's accept arm depends on it.

Framing: docs/journey-stage1a-framing.md rev 5 (Q#JR1-JR15).
2026-07-26 16:39:59 -04:00
Levi Neuwirth 08e2807fcc fix(dired): correct the reporting-channel claim #161 falsified
The module doc said an uncaught raise inside a `pmacs.async` coroutine
"goes to *errors*, not the status line". #161's COHERENCE finding shows
that is wrong, and in the worse direction: `pmacs.error` is never
defined in production, so `step()`'s guarded report is dead and the raise
falls through to a bare `error()` inside `pmacs._async.tick()` -- whose
result `EditorState::tick_async` discards with `let _ =`. The failure
reaches nowhere at all, and dired would look like it silently did
nothing.

So the per-coroutine `pcall` plus `pmacs.editor.set_status` is
load-bearing, not tidy, and the doc now says which channel is dead, which
is live, and that the acceptance suite observes the live one -- the
corollary COHERENCE draws from that finding.

The ledger records the integration, the reruns on the merged tree, and
the ops lesson that cost three CI runs: a conflicting PR has no merge
ref, so GitHub creates no `pull_request` run and nothing reports the
absence.
2026-07-25 16:14:06 -04:00
Levi Neuwirth 531fdf404e fix(dired): address PR #165 review round 1
F1 (real, small-window misbehavior). `dired.revert`'s re-seat runs after
the read settles, and `pmacs.editor.move_to_line` is AMBIENT -- it moves
whatever window is active. A user who switched buffers (or hit `q`)
while the re-read was in flight had an unrelated buffer's cursor moved
to a line index that only means something in the dired listing. The
paint was already safe because it names its buffer; the seat now runs
only while dired is still the active buffer, and `seat_cursor`'s doc
says which callers are unconditionally in the right place and why.
Pinned by a test that starts the revert, switches to a six-line file
before the pump, and asserts that buffer's cursor never moved -- and
that the dired buffer still reverts when it IS active.

F2 (a trap set for Stage 3). `fmt_size` used `%10d`, so a size past ten
digits -- 10 GB and up, ordinary for VM images and core dumps -- widened
the field and shifted mtime and name right on that line alone. Cosmetic
today, but `_layout` is exported as a contract and Stage 3's
column-classifying intercept is planned against it. It now takes
`fmt_mtime`'s discipline: exact bytes while they fit, else a
fixed-width magnitude, so precision yields to the invariant rather than
the other way round. This is not the deferred human-readable column --
the exact count still shows right up to where it cannot. Pinned with a
sparse 12 GB fixture that skips if the filesystem refuses it.

F3 (honesty and a doubled read). The symlink arm claimed the probe cost
"one syscall"; it was a full `read_dir` -- opendir plus one lstat per
child -- and on success `open_directory` immediately read the same
directory again. Since `open_directory` reads before touching editor
state and raises having changed nothing (acceptance 15's invariant), its
failure IS the "not a directory" answer: the probe is gone, one read
remains, and the comment says what it actually does. New test pins both
arms -- a symlink to a directory descends under the path the user walked
(canonicalization is lexical, so the link is not resolved), and a
symlink to a file opens with the target's contents.

F4 (deliberate failure mode). A tolerant listing recorded readdir
iterator errors without bound, and `std::fs::ReadDir` need not terminate
after yielding one. Cancellation is NOT an adequate backstop here --
which is the reason for a constant rather than a comment saying it is: a
dired listing carries no supersede key, so nothing cancels it. A
directory whose iterator produces nothing but errors now fails with the
last error the way an unopenable directory does, after
READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS; the counter resets on any entry
that materializes. Documented as untested and why: faking a failing
iterator needs the walk generic over it, a refactor with no other
consumer.

Smaller notes, all taken: READ_ONLY_LIMIT renamed NAME_VARIANT_LIMIT (it
caps `<2>`..`<99>`, nothing read-only); `fmt_perms`' omission of
setuid/setgid/sticky documented as a decision tied to the M8.3 fixture's
nine-bit parser; `format_outcome` binds the slice in the pattern instead
of re-traversing; and `pmacs.path.canonicalize`'s `to_string_lossy` is
noted as inside the existing non-UTF-8-path deferral rather than an
exception to it.

Process note, learned the hard way twice now: the round-1 dired.lua
fixes were briefly wiped because a mutation-bite helper restores with
`git checkout --`, which reverts to HEAD -- so a fix must be committed
BEFORE it is bitten, not after.
2026-07-25 15:21:26 -04:00
Levi Neuwirth f71055a206 feat(dired): the directory view (Stage 1)
Dired is the file surface, not a rider on one: before Stage 0 (#162)
pmacs had no way to open a file by path, and browsing is the half a
user reaches for when they do not already know the path. Stage 1 ships
the view.

builtin/runtime/dired.lua: one buffer per directory named by the
canonical path (Q#DR2) with an ownership check before any paint (F7);
read-only intercept plus round-trip input (Q#DR3); a `dired` major mode
carrying mode-scoped keys (Q#DR8) -- RET/f visit, ^ parent, n/p, g
revert, q quit, s sort; cursor re-seated by basename across every
wholesale repaint (Q#DR9); file visits through
`pmacs.window.display_file` and directory descent through dired's own
window (Q#DR10); `C-x d` / `C-x C-j`; and `dired.kill-when-opening`
through the config registry.

Two Rust changes, both narrow:

* `read_dir` grows per-entry tolerance behind an opt (Q#DR6). Five
  per-entry conditions used to fail the entire listing, so a plain
  refresh of a busy directory could just fail; the module doc's claim
  that a tolerant wrapper was "the package's job" was false, because
  the primitive hands Lua one structured error and no partial vec.
  Per-entry readdir/lstat/readlink failures and non-UTF-8 symlink
  targets now land in an `errors` channel; parent-level failures and
  non-UTF-8 *names* stay fatal. The tolerance travels in the settled
  payload, so the Lua boundary keeps the bare-array shape the frozen
  M8.2 fixture consumes and never has to look the job back up. The read
  ops' opts parsing now rejects unknown keys, so a typo'd `tolerant`
  cannot silently degrade to the fatal contract.

* `normalize_buffer_path` is exposed as `pmacs.path.canonicalize`
  rather than mirrored in Lua. Q#DR2 named exposure the preferred end
  state; it needs no borrow plumbing, so dired's name-dedup and
  `display_file`'s `find_buffer_for_path` dedup cannot fork, and the
  mirror's Stage 2 removal is not owed.

tests/dired_acceptance.rs covers framing items 1-16 (22 tests), driven
through real key dispatch. Item 17 is the m8_1/m8_2/m8_3 gate.

One framing claim is corrected by the substrate: R2-3 expected a
dedicated dired panel to carry its dedication across a descent, but
`display_buffer` never replaces the buffer in a slot dedicated to
another one -- it discards every side-specific parameter and falls back
to the document window (Q#BP3 2.iii). Dired does not try to unpin the
user's panel; both arms are pinned.
2026-07-25 14:54:58 -04:00