Merge pull request #203 from levineuwirth/journey-stage1b1-compile-defaults
Journey Stage 1b-1: make building discoverable (step 9)
This commit is contained in:
commit
1f290d51c3
46
COHERENCE.md
46
COHERENCE.md
|
|
@ -378,7 +378,7 @@ Full verdict table:
|
|||
| 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 |
|
||||
| 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 detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) |
|
||||
| 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 |
|
||||
| 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,12 +388,14 @@ 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 since 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**, and the underlying
|
||||
inversion is a standing bias in how new work gets bound, not three
|
||||
isolated omissions: the quote stays as written because it names the
|
||||
pattern, and the pattern is not retired until step 9 is.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1542,14 +1544,18 @@ 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 from the existing
|
||||
`ProjectKind::Cargo`; a terminal keybinding (**done**: `C-c t`, #173); a
|
||||
welcome buffer. The journey acceptance suite (§19) is the ratchet that
|
||||
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.
|
||||
|
||||
Journey Stage 1b is the named remainder: the compile binding + Cargo
|
||||
defaults, LSP spawn guidance, and the welcome buffer.
|
||||
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.
|
||||
|
||||
### Priority 2: Make workspace and location explicit
|
||||
|
||||
|
|
@ -1620,8 +1626,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 — remaining**: compile defaults, LSP-failure surfacing,
|
||||
bindings, welcome buffer.
|
||||
**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.
|
||||
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
|
||||
|
|
@ -1713,6 +1721,16 @@ available without being imposed.
|
|||
Found during the audit; fix opportunistically, ideally before this
|
||||
document is wired into CLAUDE.md/AGENTS.md as required reading:
|
||||
|
||||
- **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
|
||||
*doc comment*; the variant on line 78 is **`ProjectKind::Rust`**,
|
||||
produced by the `Cargo.toml` marker. The audit read the comment and
|
||||
named the comment. Corrected by Journey Stage 1b-1 — which also
|
||||
establishes that the Lua side never sees the variant at all:
|
||||
`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 ~250–1000 lines despite a "last verified @ `f8096ff`
|
||||
(2026-07-20)" stamp; its shadow list also omits the terminal `C-c`
|
||||
|
|
|
|||
|
|
@ -620,6 +620,60 @@ local function daemon_working_directory()
|
|||
return nil
|
||||
end
|
||||
|
||||
-- Where a run started right now would execute. Extracted from the
|
||||
-- inline expression that used to live in `pmacs.compile.run`, because
|
||||
-- the interactive command needs the SAME answer to build its prompt:
|
||||
-- a suggestion computed from a different rule than the run obeys can
|
||||
-- name a toolchain that is not at the directory the command executes
|
||||
-- in (journey Stage 1b-1 framing §3.1).
|
||||
local function resolve_cwd(explicit)
|
||||
return explicit or project_root_of_active() or daemon_working_directory()
|
||||
end
|
||||
|
||||
--- Default compile command per detected project kind, keyed by the tag
|
||||
--- `pmacs.project.detect` returns. Assign into this table from
|
||||
--- `init.lua` to add or override one:
|
||||
---
|
||||
--- pmacs.compile.defaults.go = "go build ./..."
|
||||
---
|
||||
--- Only `rust` ships seeded, and that is a decision rather than an
|
||||
--- omission: Rust has one answer, while npm/yarn/pnpm, make/cmake, and
|
||||
--- `go build` versus `go test` do not. A wrong prefill costs more than
|
||||
--- an empty one — the user must delete it before typing.
|
||||
pmacs.compile.defaults = { rust = "cargo build" }
|
||||
|
||||
-- Read `defaults[kind]` defensively. The table is public and
|
||||
-- assignable, so a metatable with a throwing `__index`, a non-string
|
||||
-- entry, or a non-table replacement all have to be survivable — the
|
||||
-- same discipline `validated_rules` keeps for a hostile rule container
|
||||
-- (round-2 finding 3). A broken `defaults` degrades to the pre-stage
|
||||
-- empty prompt; it never prevents compiling.
|
||||
local function default_command_for(kind)
|
||||
if type(kind) ~= "string" then return nil end
|
||||
local ok, value = pcall(function() return pmacs.compile.defaults[kind] end)
|
||||
if ok and type(value) == "string" and #value > 0 then return value end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Where the next compile would run, and what kind of project is
|
||||
--- detected *from that directory*. Public getter (per API
|
||||
--- conventions): `{ cwd = string|nil, kind = string|nil }`.
|
||||
---
|
||||
--- `kind` is always `detect(cwd)`, one rule regardless of which branch
|
||||
--- produced `cwd`. Note that is not the same as "`cwd` is a project
|
||||
--- root": a Cargo workspace subdirectory contains no `Cargo.toml`, is
|
||||
--- detected as `rust` by the ancestor walk, and is a perfectly good
|
||||
--- place to run `cargo build`.
|
||||
function pmacs.compile.context(explicit_cwd)
|
||||
local cwd = resolve_cwd(explicit_cwd)
|
||||
local kind = nil
|
||||
if cwd then
|
||||
local ok, proj = pcall(pmacs.project.detect, cwd)
|
||||
if ok and proj and type(proj.kind) == "string" then kind = proj.kind end
|
||||
end
|
||||
return { cwd = cwd, kind = kind }
|
||||
end
|
||||
|
||||
local function format_exit_marker(label, ev)
|
||||
if ev.kind == "exited" then
|
||||
return string.format("\n[%s exited with code %d]\n", label, ev.code or 0)
|
||||
|
|
@ -762,7 +816,7 @@ local function start_run(slot, cmdline, opts)
|
|||
if cur and not pmacs.compile.is_generated_buffer(cur) then
|
||||
slot.prev = cur
|
||||
end
|
||||
local cwd = opts.cwd or project_root_of_active() or daemon_working_directory()
|
||||
local cwd = resolve_cwd(opts.cwd)
|
||||
|
||||
-- Supersede (Q#CM9): terminate the old group and tombstone its
|
||||
-- pump entry; its terminal event still drives forget.
|
||||
|
|
@ -1132,13 +1186,27 @@ pmacs.command.define {
|
|||
description = "Compile: run a command in a streaming *compilation* buffer (M-x compile).",
|
||||
fn = function()
|
||||
local last = pmacs.compile._last
|
||||
-- Captured ONCE, here. `pmacs.minibuffer.read` is asynchronous and
|
||||
-- nothing freezes the active window while a prompt is open, so
|
||||
-- re-resolving inside `on_accept` would let the prompt offer
|
||||
-- `cargo build` for A and execute in B. Sharing the resolver is
|
||||
-- necessary and not sufficient; the resolution has to be captured.
|
||||
-- This is Journey Stage 1a's `commit_to` discipline on a smaller
|
||||
-- seam — the mechanism differs, the failure prevented is the same.
|
||||
local ctx = pmacs.compile.context()
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Compile command: ",
|
||||
history = "compile",
|
||||
initial = last and last.cmdline or "",
|
||||
-- `_last` still wins: a user who ran `cargo test` once gets it
|
||||
-- back rather than being reset to the project default.
|
||||
initial = last and last.cmdline or default_command_for(ctx.kind) or "",
|
||||
on_accept = function(cmdline)
|
||||
if cmdline == nil or cmdline == "" then return end
|
||||
pmacs.compile.run(cmdline)
|
||||
-- `ctx.cwd` passes through verbatim, INCLUDING nil: a nil cwd
|
||||
-- means every resolution step failed, and the header renders
|
||||
-- "(unknown)" exactly as before. Re-resolving here would
|
||||
-- reintroduce the drift for the case least able to tolerate it.
|
||||
pmacs.compile.run(cmdline, { cwd = ctx.cwd })
|
||||
end,
|
||||
}
|
||||
end,
|
||||
|
|
@ -1186,3 +1254,16 @@ pmacs.keymap.bind { scope = "global", sequence = "M-g n", command = "error.next"
|
|||
pmacs.keymap.bind { scope = "global", sequence = "M-g p", command = "error.previous" }
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-x `", command = "error.next" }
|
||||
pmacs.keymap.bind { scope = "global", sequence = "M-!", command = "shell.command" }
|
||||
-- Journey step 9 (COHERENCE §2): running a build had no binding at all,
|
||||
-- while `C-c @ C-M-s` opened all folds. `C-c c` is free, sits under the
|
||||
-- established `C-c` prefix, and does not collide with CUA copy (that is
|
||||
-- `M-w`). Bound here rather than in `default.lua` because a runtime
|
||||
-- module owns its own global keys — `terminal.lua` binds `C-c t`,
|
||||
-- `lsp.lua` binds `C-c o`.
|
||||
--
|
||||
-- Two inherited reachability limits, both pre-existing: inside a
|
||||
-- terminal window `C-c` is consumed as the escape key, and the repl
|
||||
-- package binds `C-c` at buffer scope. `M-x compile.run` still works in
|
||||
-- both. `compile.recompile` gets no global chord — `g` in
|
||||
-- `*compilation*` already covers rerun.
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-c c", command = "compile.run" }
|
||||
|
|
|
|||
|
|
@ -253,6 +253,100 @@ 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
|
||||
|
||||
- **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.
|
||||
- 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):
|
||||
|
||||
```sh
|
||||
git fetch githubsucks
|
||||
git worktree add ../pmacs-journey-1b1 \
|
||||
-b journey-stage1b1-compile-defaults \
|
||||
githubsucks/journey-stage1b1-compile-defaults
|
||||
```
|
||||
|
||||
## Generated-buffer immutability lane (Arc: workbench primitives) — STAGE 1 MERGED; STAGE 2 IS NEXT
|
||||
|
||||
**Framing #188 (revision 7) and Stage 1 #191 are both on `main` @
|
||||
|
|
|
|||
|
|
@ -104,6 +104,51 @@ 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`,
|
||||
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
|
||||
change. The ratchet now carries steps 2, 3, 5 and **9**.
|
||||
- **Sharing a resolver is not capturing one.** `pmacs.minibuffer.read`
|
||||
is asynchronous and nothing freezes the active window while a prompt
|
||||
is open, so having the prompt and `compile.run` call the *same* cwd
|
||||
resolver still let them disagree: the user could be offered
|
||||
`cargo build` for A and given a run in B by clicking away
|
||||
mid-prompt. The interactive command captures `pmacs.compile.context()`
|
||||
once and passes its `cwd` through to the run. **This is Stage 1a's
|
||||
`commit_to` lesson on a smaller seam** — capture at request time,
|
||||
never re-derive when the async work lands. Review found it; the
|
||||
first framing had the weaker design and said it was sufficient.
|
||||
- **A pin that never crosses the accept boundary cannot see that
|
||||
class of bug.** Every originally-proposed pin compared values the
|
||||
prompt and the resolver already agreed on, so the defect above
|
||||
passed all of them. Two pins now accept the prompt and observe a
|
||||
real process.
|
||||
- **`ProjectKind::Cargo` does not exist** — `COHERENCE.md` named it
|
||||
twice, citing `src/project.rs:77`, which is the *doc comment*; the
|
||||
variant on line 78 is `ProjectKind::Rust`. Lua never sees the
|
||||
variant anyway: `pmacs.project.detect` returns the tag string
|
||||
`"rust"`. Corrected in both COHERENCE sites and recorded in its §24.
|
||||
- **The compile fallback cwd is the process cwd, which under `cargo
|
||||
test` is the pmacs repo — itself a Cargo project.** Any pin
|
||||
asserting the *absence* of a Cargo suggestion that reaches the
|
||||
fallback reports pmacs's own `Cargo.toml`. Bite D confirmed it: with
|
||||
the kind derived from the process cwd, the plain-fixture prefill pin
|
||||
still passed and only the nested-project pin caught it.
|
||||
`set_search_boundary` is no defence — it clamps only a walk starting
|
||||
*below* the boundary.
|
||||
- **Named limitation, deliberately unfixed:** after `pmacs <dir>` the
|
||||
active buffer is dired's and **pathless** (`pmacs.buffer.create`
|
||||
assigns no path; dired compensates through its own module-local
|
||||
`handle_for_buffer`), so the cwd falls through to the process cwd.
|
||||
Launched from elsewhere that is the wrong directory. The fix is
|
||||
§8's execution-location model, not a reach into dired's private
|
||||
table. What is guaranteed is that the failure stays *coherent*: the
|
||||
suggestion always describes the directory the run will use.
|
||||
- **Journey arc (P1) — Stage 1a LANDED**
|
||||
(`docs/journey-stage1a-framing.md`). `pmacs .` opens a directory
|
||||
instead of exiting 1, on **one** path: `resolve_target_buffer` gained a
|
||||
|
|
|
|||
|
|
@ -230,6 +230,12 @@ Not in 1a — Stage 1b: a compile keybinding and `cargo build`/`test`
|
|||
defaults from the existing `ProjectKind::Cargo`, LSP spawn-failure
|
||||
guidance (§1.2), a welcome buffer.
|
||||
|
||||
*(Correction, added by Stage 1b-1: there is no `ProjectKind::Cargo`. The
|
||||
variant is `ProjectKind::Rust`; this line inherited the error from
|
||||
`COHERENCE.md` §20, now recorded in its §24. Left in place rather than
|
||||
rewritten — this document records what was planned — but flagged so the
|
||||
wrong type name does not survive by being quoted.)*
|
||||
|
||||
---
|
||||
|
||||
## 2. Ground truth (scouted 2026-07-26, `main` @ `d400f30`; re-verified rev 4)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,691 @@
|
|||
# Journey Stage 1b-1 — make building discoverable
|
||||
|
||||
**Status: framing, rev 2 — awaiting review round 2.**
|
||||
**Serves `COHERENCE.md` §2 (the golden product journey, step 9), §19
|
||||
(coherence acceptance tests), §20 Priority 1.**
|
||||
|
||||
## 0. Revision history
|
||||
|
||||
- rev 2 (2026-07-30) — review round 1. Two blocking, two major; all four
|
||||
accepted, all four verified in the code first.
|
||||
- **§3.1 did not actually prevent the drift it claimed to.**
|
||||
`pmacs.minibuffer.read` is asynchronous: `on_accept` runs later, and
|
||||
the active window can change while the prompt is open. `run` then
|
||||
re-resolved the cwd from whatever was active *at accept time*, so the
|
||||
prompt could say `cargo build` for A and execute in B. **Sharing one
|
||||
resolver is not enough — the resolution has to be captured.** The
|
||||
interactive `fn` now captures `context()` and passes its `cwd`
|
||||
through to `run`. This is the Stage 1a lesson repeating: the
|
||||
destination is captured at request time, not re-derived at commit
|
||||
time (`commit_to`, §4.4 of that framing).
|
||||
- **No pin accepted the prompt or observed a spawned run.** N1–N5 all
|
||||
compared values that the prompt and the resolver had already agreed
|
||||
on; a wrong cwd inside `on_accept` — exactly the defect above —
|
||||
passed every one of them. Two pins now cross the accept boundary:
|
||||
N3 (the captured directory survives a window switch) and N4 (accept
|
||||
`cargo build` in a real Cargo fixture, assert the `Directory:` header
|
||||
and a clean exit).
|
||||
- **N3's named falsifier was not discriminating.**
|
||||
`project_root_of_active()` already detects from the active file and
|
||||
returns the *innermost* root, so re-detecting from that root yields
|
||||
`node` again in `mixed_fixture/sub`. The mutation rev 1 named would
|
||||
have left the pin green. N3 is repurposed to the prompt-to-run
|
||||
handoff, and the mixed-fixture pin (now N5) states a falsifier it
|
||||
actually catches.
|
||||
- **§6.1 contradicted §3.1.** A Cargo *workspace subdirectory*
|
||||
contains no `Cargo.toml` and correctly receives `cargo build`, so
|
||||
"never offered `cargo build` for a directory with no `Cargo.toml`"
|
||||
was false on the design's own terms. Reworded to *no detected Cargo
|
||||
project*.
|
||||
- rev 1 (2026-07-30) — first framing. Scouted against `githubsucks/main`
|
||||
@ `22df6ab` (test-ambient isolation framing #201).
|
||||
|
||||
## 1. What this stage is, and what it is not
|
||||
|
||||
`COHERENCE.md` §20 Priority 1 names the remainder of the journey arc:
|
||||
|
||||
> Journey Stage 1b is the named remainder: the compile binding + Cargo
|
||||
> defaults, LSP spawn guidance, and the welcome buffer.
|
||||
|
||||
Those three are unrelated in mechanism, in failure mode, and in cost.
|
||||
This stage takes **only the first**: journey step 9, *build or test the
|
||||
project*. LSP spawn guidance (step 6) and the welcome buffer (step 4)
|
||||
get their own stages; §7 states the split and why.
|
||||
|
||||
Nothing here is a new Rust primitive. The stage is Lua, tests, and docs.
|
||||
|
||||
## 2. Ground truth
|
||||
|
||||
Every claim below was read in the tree at `22df6ab`, not inferred from a
|
||||
name.
|
||||
|
||||
### 2.1 `compile.run` has no binding, and the docs say so
|
||||
|
||||
`compile.lua` binds four global sequences (`:1183-1188`): `M-g n`,
|
||||
`M-g p`, `C-x \``, `M-!`. `bind_slot_keys` (`:220-240`) binds
|
||||
`RET`/`n`/`p`/`q`/`C-c C-k`/`g` at **buffer** scope inside a generated
|
||||
buffer. `builtin/keymaps/default.lua` never mentions compile. There is
|
||||
no third keymap file — `builtin/keymaps/` contains `default.lua` alone.
|
||||
|
||||
`docs/keybindings.md:191-192` states it outright:
|
||||
|
||||
> `compile.run` and `compile.recompile` are available through `M-x`; no
|
||||
> global key is assigned to them.
|
||||
|
||||
### 2.2 The first prompt is empty
|
||||
|
||||
```lua
|
||||
-- builtin/runtime/compile.lua:1131-1144
|
||||
name = "compile.run",
|
||||
fn = function()
|
||||
local last = pmacs.compile._last
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Compile command: ",
|
||||
history = "compile",
|
||||
initial = last and last.cmdline or "",
|
||||
```
|
||||
|
||||
`_last` is set only by a completed `pmacs.compile.run` (`:1094`), so on a
|
||||
fresh session `initial` is `""`. The user is asked what to build and
|
||||
given nothing to build with.
|
||||
|
||||
`initial` does reach the user: `Minibuffer::begin` calls
|
||||
`replace_contents(&session.initial)` (`src/minibuffer.rs:106-107`), and
|
||||
`contents()` (`:129`) reads it back. The prefill mechanism works; it is
|
||||
handed an empty string.
|
||||
|
||||
### 2.3 `ProjectKind::Cargo` does not exist — the variant is `Rust`
|
||||
|
||||
**`COHERENCE.md` is wrong here, in two places.** Its §2 step-9 row and
|
||||
its §20 Priority 1 paragraph both say "`ProjectKind::Cargo` existing
|
||||
(`src/project.rs:77`)". Line 77 is a doc comment; the variant on line 78
|
||||
is `Rust`:
|
||||
|
||||
```rust
|
||||
// src/project.rs:77-78
|
||||
/// A Cargo workspace (`Cargo.toml`).
|
||||
Rust,
|
||||
```
|
||||
|
||||
The marker that produces it is `{ name: "Cargo.toml", kind:
|
||||
ProjectKind::Rust, is_directory: false }` (`:148-150`). The audit read
|
||||
the comment and named the comment. This framing corrects both COHERENCE
|
||||
sites (§8).
|
||||
|
||||
The correction is not cosmetic: it decides what the Lua side matches on.
|
||||
|
||||
### 2.4 Lua already sees the project kind — as a tag string
|
||||
|
||||
`pmacs.project.detect(path)` returns a table (`src/lua_bindings/mod.rs:11683-11694`):
|
||||
|
||||
```rust
|
||||
t.set("root", root.display().to_string())?;
|
||||
t.set("kind", kind.tag())?;
|
||||
t.set("language_id", kind.default_language_id())?;
|
||||
```
|
||||
|
||||
`ProjectKind::Rust.tag()` is `"rust"` (`src/project.rs:101`). So the Lua
|
||||
key is the string `"rust"`, and **no Rust change is needed to learn the
|
||||
project kind**.
|
||||
|
||||
`detect` accepts a directory as well as a file: `walk_for_marker` uses
|
||||
`start` unchanged when it is not a file (`src/project.rs:225-229`).
|
||||
|
||||
### 2.5 The cwd is resolved *inside* `run`, after the prompt has closed
|
||||
|
||||
```lua
|
||||
-- builtin/runtime/compile.lua:765
|
||||
local cwd = opts.cwd or project_root_of_active() or daemon_working_directory()
|
||||
```
|
||||
|
||||
The interactive `fn` that builds the prompt cannot see this. Any
|
||||
suggestion computed in the `fn` today would be computed from a different
|
||||
rule than the one the run obeys — which is the failure this stage must
|
||||
not ship. §3.1 is about closing that gap before adding the suggestion,
|
||||
not after.
|
||||
|
||||
### 2.6 The active buffer at the journey moment is **pathless**
|
||||
|
||||
`project_root_of_active()` (`:600-609`) needs `buf:path()`. Directly
|
||||
after `pmacs .` the active buffer is dired's, created by
|
||||
`pmacs.buffer.create(name)` (`dired.lua:506`) — a name, never a path.
|
||||
`buf:path()` returns `file_path()` mapped to a string
|
||||
(`src/lua_bindings/mod.rs:1242-1248`), so it is `nil`.
|
||||
|
||||
This is not a guess. dired itself compensates, and its own helper is the
|
||||
evidence:
|
||||
|
||||
```lua
|
||||
-- builtin/runtime/dired.lua:205-217
|
||||
local function current_directory()
|
||||
local buf = pmacs.window.buffer()
|
||||
if buf ~= nil then
|
||||
local ok, path = pcall(function() return buf:path() end)
|
||||
if ok and path then ... end
|
||||
local h = handle_for_buffer(buf) -- <-- the pathless case
|
||||
if h then return h.path end
|
||||
end
|
||||
return canonicalize(".")
|
||||
end
|
||||
```
|
||||
|
||||
`handle_for_buffer` is a **module-local** table. Compile cannot reach it,
|
||||
and reaching for it would make compile depend on dired — a new
|
||||
interaction island for one directory string. §5 decides against it and
|
||||
§6 states the residual gap.
|
||||
|
||||
### 2.7 The last-resort cwd is the *process* cwd, evaluated at call time
|
||||
|
||||
`daemon_working_directory()` reads
|
||||
`pmacs.instance.identity().working_directory`, which is:
|
||||
|
||||
```rust
|
||||
// pmacs-protocol/src/message.rs:1703-1706
|
||||
working_directory: std::env::current_dir()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
```
|
||||
|
||||
Two consequences, both load-bearing:
|
||||
|
||||
- **In production**, `pmacs .` is launched from the project directory, so
|
||||
the fallback happens to be right. `pmacs ~/code/proj` launched from
|
||||
`~` resolves to `~`, and the fallback is wrong. That is a pre-existing
|
||||
step-9 defect this stage does not fix (§6.1).
|
||||
- **In tests**, it is the *test runner's* cwd — the pmacs repo root,
|
||||
**which is a Cargo project**. `compile_mode_acceptance.rs:1721-1733`
|
||||
already pins exactly this (`r1f8_inherited_cwd_resolves_to_the_daemon_working_directory`),
|
||||
and `:1382` already warns that "fallback would search the test
|
||||
process's cwd". Any pin that asserts *no* Cargo suggestion is at the
|
||||
mercy of where `cargo test` was invoked unless it is designed to never
|
||||
reach the fallback. §5.2 designs for that; it is the single largest
|
||||
trap in this stage.
|
||||
|
||||
### 2.8 `C-c c` is free, and `C-c` is not CUA copy
|
||||
|
||||
Global `C-c` sequences in the tree: `C-c a`/`f`/`h`/`H`/`i`/`o`/`r`/`s`/`y`
|
||||
(`lsp.lua`), `C-c t` (`terminal.lua:203`), `C-c @ …` (folding).
|
||||
`C-c c` is unbound. Copy is `M-w` (`builtin/keymaps/default.lua:114`) —
|
||||
the CUA trio was deliberately not taken — so binding under the `C-c`
|
||||
prefix collides with nothing in the default map.
|
||||
|
||||
`terminal.lua` binds its own `C-c t`; `lsp.lua` binds its own `C-c o`.
|
||||
A runtime module owning its global binding is the established pattern,
|
||||
and this stage follows it rather than editing `default.lua`.
|
||||
|
||||
### 2.9 dired already decided the prompt shape
|
||||
|
||||
`C-x d` prefills its prompt and **deliberately refuses a completion
|
||||
source** (`dired.lua:749-756`): a candidate list makes RET-on-empty open
|
||||
whatever sorts first, and a selected candidate shadows typed text. The
|
||||
same reasoning applies to a compile command, so this stage prefills and
|
||||
adds no completion source.
|
||||
|
||||
## 3. Design
|
||||
|
||||
### 3.1 One resolution, consumed twice
|
||||
|
||||
Extract the `:765` expression into a helper and expose a read-only view
|
||||
of it:
|
||||
|
||||
```lua
|
||||
local function resolve_cwd(explicit)
|
||||
return explicit or project_root_of_active() or daemon_working_directory()
|
||||
end
|
||||
|
||||
--- Where the next compile would run, and what kind of project is
|
||||
--- detected *from that directory*. Public getter (API conventions):
|
||||
--- `{ cwd = string|nil, kind = string|nil }`.
|
||||
function pmacs.compile.context(explicit_cwd)
|
||||
```
|
||||
|
||||
`run` calls `resolve_cwd(opts.cwd)`; the interactive `fn` calls
|
||||
`pmacs.compile.context()`.
|
||||
|
||||
**Sharing the resolver is necessary and not sufficient.**
|
||||
`pmacs.minibuffer.read` is asynchronous — `on_accept` runs an arbitrary
|
||||
time later, and nothing freezes the active window while a prompt is
|
||||
open. A mouse click, a second frontend, or a background open can change
|
||||
what `project_root_of_active()` answers between the prompt and the RET.
|
||||
Two calls to the same resolver at two different moments are still two
|
||||
different answers, and the user is then shown a suggestion for A and
|
||||
given a run in B.
|
||||
|
||||
So the interactive command **captures** the resolution and hands it
|
||||
through:
|
||||
|
||||
```lua
|
||||
fn = function()
|
||||
local last = pmacs.compile._last
|
||||
local ctx = pmacs.compile.context() -- captured once, here
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Compile command: ",
|
||||
history = "compile",
|
||||
initial = last and last.cmdline or default_for(ctx.kind) or "",
|
||||
on_accept = function(cmdline)
|
||||
if cmdline == nil or cmdline == "" then return end
|
||||
pmacs.compile.run(cmdline, { cwd = ctx.cwd }) -- the same ctx
|
||||
end,
|
||||
}
|
||||
end
|
||||
```
|
||||
|
||||
This is Journey Stage 1a's `commit_to` lesson on a smaller seam: the
|
||||
destination is captured when the request is made and revalidated at
|
||||
commit, never re-derived from whatever happens to be ambient when the
|
||||
async work lands. The mechanism differs — compile needs no scope
|
||||
override, only the value — but the failure it prevents is the same one.
|
||||
|
||||
`ctx.cwd` is passed through verbatim, **including `nil`**. A `nil` cwd
|
||||
means every resolution step failed, and `run`'s header renders
|
||||
`(unknown)` exactly as it does today; substituting a re-resolution there
|
||||
would reintroduce the drift for the one case least able to tolerate it.
|
||||
|
||||
**Only the interactive command captures.** `pmacs.compile.run(cmdline)`
|
||||
called programmatically still resolves at call time, which is what a
|
||||
caller with no prompt in between means by "here".
|
||||
|
||||
With both halves in place: **the suggestion is a function of the
|
||||
directory the command will execute in**, and the two cannot drift —
|
||||
neither across the two resolutions nor across the wait for input.
|
||||
|
||||
`kind` is `pmacs.project.detect(cwd).kind` — detection *from* the cwd,
|
||||
which is not the same as "the cwd is the root": `opts.cwd = /proj/src`
|
||||
in a Cargo workspace yields kind `rust` with root `/proj`. That is
|
||||
correct (cargo works from a subdirectory) and is stated so a reader does
|
||||
not read `kind` as "this directory is a project root".
|
||||
|
||||
`project_root_of_active()` already calls `detect` once to get a root;
|
||||
`context` then calls it again on the result. The second call is
|
||||
redundant in that branch and is kept anyway, because the alternative is
|
||||
two different rules for where `kind` comes from depending on which
|
||||
branch produced the cwd. One rule, stated as: **`kind` is always
|
||||
`detect(cwd)`.**
|
||||
|
||||
### 3.2 The default table
|
||||
|
||||
```lua
|
||||
--- Default compile command per detected project kind, keyed by the
|
||||
--- tag `pmacs.project.detect` returns. Assign into this table from
|
||||
--- `init.lua` to add or override one.
|
||||
pmacs.compile.defaults = { rust = "cargo build" }
|
||||
```
|
||||
|
||||
**Only `rust` is seeded, and that is a decision rather than an
|
||||
omission.** Rust has one answer. Node does not (`npm` / `yarn` / `pnpm`
|
||||
/ a `scripts.build` that may not exist); Python does not; Go's build and
|
||||
test are different commands with equal claim. A wrong prefill is worse
|
||||
than an empty one — the user must first delete it, then type. The table
|
||||
exists so a user or a package can add the answer *they* know.
|
||||
|
||||
`cargo test` is reachable by editing the prefill. The prompt prefills
|
||||
one string; offering both would need a candidate list, which §2.9
|
||||
already ruled out for this prompt.
|
||||
|
||||
### 3.3 Precedence at the prompt
|
||||
|
||||
```
|
||||
last.cmdline -- unchanged; a session that has compiled keeps its command
|
||||
or defaults[kind] -- new
|
||||
or "" -- unchanged
|
||||
```
|
||||
|
||||
`last` winning is deliberate: a user who ran `cargo test` once should get
|
||||
`cargo test` back, not be reset to `cargo build`. This is a preservation
|
||||
pin (§5.4, P1), not an accident of ordering.
|
||||
|
||||
### 3.4 The table is user-writable, so reading it is guarded
|
||||
|
||||
`pmacs.compile.defaults` is public and assignable, which means a
|
||||
metatable with a throwing `__index`, a non-string value, or a
|
||||
non-table replacement all have to be survivable. The module already
|
||||
holds this discipline for a hostile rule container (`validated_rules`,
|
||||
round-2 finding 3: shell-command "must neither surface compile-rule
|
||||
warnings nor fail on a hostile rule container").
|
||||
|
||||
The lookup therefore runs under `pcall` and accepts a value only when it
|
||||
is a non-empty string. Anything else yields `""` — the pre-stage
|
||||
behavior. **A broken `defaults` degrades to today's prompt; it never
|
||||
prevents compiling.**
|
||||
|
||||
### 3.5 The binding
|
||||
|
||||
```lua
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-c c", command = "compile.run" }
|
||||
```
|
||||
|
||||
In `compile.lua`, beside the four existing global binds (§2.8).
|
||||
|
||||
Two reachability limits it inherits, both pre-existing and both stated
|
||||
rather than discovered later:
|
||||
|
||||
- Inside a **terminal** window `C-c` is consumed as the escape key, so
|
||||
`C-c c` does not arrive. `COHERENCE.md` §2 already records this for
|
||||
`C-c t`. `M-x compile.run` still works there.
|
||||
- The repl package binds `C-c` at **buffer** scope
|
||||
(`builtin/packages/repl/init.lua:300`), which shadows the global
|
||||
prefix in a repl buffer. Same escape hatch.
|
||||
|
||||
`compile.recompile` gets no global binding: `g` in `*compilation*`
|
||||
already covers rerun, and adding a second global chord for it is scope
|
||||
this stage has no journey argument for.
|
||||
|
||||
## 4. What this changes for the journey
|
||||
|
||||
Walking `COHERENCE.md` §2 on a Rust project, unconfigured:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| launch | `pmacs .` lists the directory (Stage 1a) | unchanged |
|
||||
| open a file | `RET` visits it (Stage 1a) | unchanged |
|
||||
| build | *no key exists*; `M-x compile.run` → empty prompt | `C-c c` → `Compile command: cargo build` |
|
||||
| accept | — | RET runs it in the detected root |
|
||||
| errors | `M-g n` walks them | unchanged |
|
||||
|
||||
Step 9's verdict row moves from **Partial** to **Works**; step 10 stops
|
||||
being gated on the user already knowing `M-x compile.run`.
|
||||
|
||||
## 5. Acceptance
|
||||
|
||||
### 5.0 Two labels, as Stage 1a established
|
||||
|
||||
**N** — new behavior, must fail on full revert. **P** — preservation,
|
||||
legitimately green on the pre-image, falsified only by a named targeted
|
||||
mutation. Stage 1a's §6.0 is the reason the distinction is kept: an
|
||||
equivalence assertion between two implementations that already agree
|
||||
proves nothing.
|
||||
|
||||
Every pin below names the mutation that falsifies it. `scripts/bite` is
|
||||
run against the suite before the PR opens.
|
||||
|
||||
### 5.1 Where the pins live
|
||||
|
||||
- **`tests/journey_acceptance.rs`** gains a step-9 section. This file is
|
||||
the ratchet — *stages add rows, none removes them*. Its pins go
|
||||
through the **real** entry points: `EditorState::open` on a directory,
|
||||
a dispatched `RET`, a dispatched `C-c c`. Nothing calls
|
||||
`pmacs.compile.context()` directly in this file.
|
||||
- **`tests/compile_mode_acceptance.rs`** gains the module-contract pins:
|
||||
`context()`'s shape, and the hostile-table guards.
|
||||
|
||||
### 5.2 The fixture problem, and its only safe shape
|
||||
|
||||
Per §2.7, the last-resort cwd is the test runner's cwd, and the test
|
||||
runner's cwd is a Cargo project. **A pin that asserts "no `cargo build`
|
||||
suggestion" and reaches the fallback will report the pmacs repo's own
|
||||
`Cargo.toml` as the fixture's answer.** It would pass or fail on where
|
||||
`cargo test` was invoked from.
|
||||
|
||||
The negative pins are therefore built so the fallback is **never
|
||||
consulted**: the fixture carries a *different* project marker, so
|
||||
`project_root_of_active()` resolves inside the fixture and returns
|
||||
before `daemon_working_directory()` is reached.
|
||||
|
||||
```
|
||||
rust_fixture/ Cargo.toml, main.rs
|
||||
mixed_fixture/ Cargo.toml, main.rs
|
||||
sub/ package.json, index.js
|
||||
```
|
||||
|
||||
`mixed_fixture` is what makes N5 discriminating: the file opened is
|
||||
`sub/index.js`, the nearest marker is `package.json` (kind `node`, no
|
||||
default), and the *outer* marker is Cargo. A suggestion computed from
|
||||
anything other than the resolved cwd — the outermost marker, the launch
|
||||
directory, the process cwd — produces `cargo build` here. The correct
|
||||
implementation produces `""`.
|
||||
|
||||
`pmacs.project.set_search_boundary` is set to the fixture root in each
|
||||
test that detects, so a stray marker above the tempdir (a developer's
|
||||
`/tmp/.git`) cannot leak in. Note what it does **not** do: it clamps the
|
||||
upward walk from a start *below* the boundary, so it is no protection at
|
||||
all for the fallback path, whose start is the repo root. That is why the
|
||||
fixture shape above, not the boundary call, is the actual defense.
|
||||
|
||||
### 5.3 New-behavior pins
|
||||
|
||||
**N1 — the chord reaches the command.**
|
||||
Launch on `rust_fixture`, `RET` on `main.rs`, dispatch `C-c c`; assert
|
||||
`pmacs.minibuffer.is_active()`.
|
||||
*Falsifier:* remove the `keymap.bind` line — the chord is unbound, no
|
||||
session opens.
|
||||
*Why it is separate from N2:* a prefill assertion alone would stay green
|
||||
if the binding were removed and the prompt were opened some other way.
|
||||
The binding is the thing COHERENCE says is missing; it gets its own pin.
|
||||
|
||||
**N2 — the prompt is prefilled from the project kind.**
|
||||
Same walk; assert `pmacs.minibuffer.contents() == "cargo build"`.
|
||||
*Falsifier:* drop the `defaults[kind]` term from the precedence chain —
|
||||
contents become `""`.
|
||||
|
||||
**N3 — the prompt's directory survives a window switch.**
|
||||
Launch on `rust_fixture`, `RET` on `main.rs`, dispatch `C-c c` — the
|
||||
prompt is now open and has captured A. **Then open a file in a second,
|
||||
unrelated directory B**, so the active buffer's project is no longer A.
|
||||
Replace the minibuffer contents with `pwd` and dispatch `RET`.
|
||||
Assert `*compilation*` contains `Directory: <A>` **and** that `pwd`'s own
|
||||
output is `<A>` — two independent readings of the same claim, one from
|
||||
the header pmacs writes and one from the shell that actually ran.
|
||||
*Falsifier:* drop `{ cwd = ctx.cwd }` from `on_accept`, i.e. rev 1's
|
||||
design. `run` re-resolves at accept time, finds B, and both readings say
|
||||
B.
|
||||
*Why `pwd` and not `cargo build`:* the subject here is the directory, and
|
||||
a cheap command keeps the pin's failure message about the directory. N4
|
||||
is the pin that runs the real thing.
|
||||
*Why the contents are set rather than typed:* the prompt is opened
|
||||
through the real chord; only the editing of an already-open prompt is
|
||||
short-circuited, which is the same split `find_file_acceptance` documents
|
||||
at its head.
|
||||
|
||||
**N4 — the offered command runs, in the offered directory.**
|
||||
A real Cargo fixture: `Cargo.toml`, `src/main.rs` with an empty `main`.
|
||||
Launch on it, `RET` on `main.rs`, dispatch `C-c c`, dispatch `RET`
|
||||
**without editing** — accepting exactly what was offered. Assert
|
||||
`*compilation*` contains `Directory: <fixture>` and the clean-exit marker
|
||||
`[compilation exited with code 0]`.
|
||||
*Falsifier:* the same `{ cwd = ... }` removal as N3, and independently
|
||||
any prefill that is not a runnable command.
|
||||
*Why this pin has to exist:* every other pin in this stage compares
|
||||
values that the prompt and the resolver already agree on. A wrong
|
||||
directory inside `on_accept` — the exact defect rev 1 shipped — passes
|
||||
all of them. This one crosses the accept boundary and observes a real
|
||||
process.
|
||||
*Operational detail:* the fixture builds into its own tempdir `target/`,
|
||||
so it takes no lock the enclosing `cargo test` holds and leaves nothing
|
||||
behind. The pin runs when `cargo` resolves on PATH and skips with a
|
||||
message otherwise; **`PMACS_REQUIRE_CARGO_BUILD` only tolerates
|
||||
absence** — presence of the binary decides execution, and the variable
|
||||
makes a missing binary fatal in CI. That is the `PMACS_REQUIRE_BASH`
|
||||
arming shape from #200, and it is stated here because getting the
|
||||
polarity backwards is how a required pin becomes a silent skip.
|
||||
*Named risk:* this is the only pin that depends on a working toolchain in
|
||||
the fixture directory. If it proves flaky in CI, the fallback is to keep
|
||||
the accept-and-observe shape and substitute a command with no toolchain
|
||||
dependency — the prompt-to-run handoff is the claim, and `cargo build` is
|
||||
the most faithful witness of it, not the only possible one.
|
||||
|
||||
**N5 — the suggestion follows the directory the run will use.**
|
||||
Launch on `mixed_fixture`, `RET` into `sub/index.js`, dispatch `C-c c`;
|
||||
assert contents are `""` and `context().kind == "node"`.
|
||||
*Falsifier:* a rule that derives the kind from anything other than the
|
||||
resolved cwd. The plausible one is the **launch directory** — Stage 1a
|
||||
made it prominent, and `mixed_fixture` was launched on the Cargo root, so
|
||||
that rule yields `cargo build` here. So does deriving it from the process
|
||||
cwd.
|
||||
*What it does not catch, stated because rev 1 claimed otherwise:*
|
||||
re-detecting from `project_root_of_active()`'s answer. That helper
|
||||
already returns the **innermost** root, so detecting from it yields
|
||||
`node` again and this pin stays green. Rev 1 named that mutation as the
|
||||
falsifier; it is not one.
|
||||
|
||||
**N6 — `context()` is total in a launched session.**
|
||||
Property, not a constant, because the value is environment-dependent
|
||||
(§2.7): after a launch, `context().cwd` is non-nil, and `context().kind`
|
||||
equals `pmacs.project.detect(context().cwd)`'s kind (both nil, or both
|
||||
the same string). Asserted with a dired buffer active — the pathless
|
||||
case — so the fallback branch is the one under test.
|
||||
*Falsifier:* make `resolve_cwd` return `nil` when the active buffer has
|
||||
no path, i.e. drop the `daemon_working_directory()` term.
|
||||
*What it deliberately does not assert:* which directory. Pinning that
|
||||
would pin the test runner's cwd.
|
||||
|
||||
### 5.4 Preservation pins
|
||||
|
||||
**P1 — `_last` still outranks the kind default.**
|
||||
Run a compile with an explicit cmdline that is not `cargo build`, then
|
||||
open the prompt in the Rust fixture; assert the contents are the last
|
||||
cmdline.
|
||||
*Targeted mutation:* reorder the precedence chain to put `defaults[kind]`
|
||||
first. Green on the pre-image (there was no default), red under the
|
||||
mutation.
|
||||
|
||||
**P2 — a hostile `defaults` cannot break compiling.**
|
||||
Three cases in `compile_mode_acceptance`: `defaults` replaced by a
|
||||
non-table; a `__index` metatable that raises; a non-string entry for
|
||||
`rust`. In all three the prompt opens with `""` and `compile.run` still
|
||||
executes a typed command.
|
||||
*Targeted mutation:* remove the `pcall` / type guard — the raising case
|
||||
propagates out of the command and no prompt opens.
|
||||
|
||||
**P3 — the existing compile bindings are unchanged.**
|
||||
`M-g n`, `M-g p`, `C-x \``, `M-!` still dispatch to their commands, and
|
||||
`compile.run` is still reachable through `M-x`.
|
||||
*Targeted mutation:* the new `bind` call written as an `unbind`+`bind`
|
||||
pair over the wrong sequence.
|
||||
|
||||
### 5.5 Gates
|
||||
|
||||
The full suite from `CLAUDE.md`, plus `compile_mode_acceptance`,
|
||||
`journey_acceptance`, `dired_acceptance`, and `find_file_acceptance` as
|
||||
the touched suites. Local runs must control all five bootstrap-storage
|
||||
variables (`XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`,
|
||||
`XDG_CACHE_HOME`, `PMACS_STATE_HOME`) — the ambient-root isolation lane
|
||||
(#201) is **framing only**, so the workaround is still required and
|
||||
`compile_mode_acceptance` is one of the suites that goes red without it.
|
||||
|
||||
## 6. Named limitations — stated, not discovered later
|
||||
|
||||
### 6.1 `pmacs <dir>` from elsewhere still resolves the wrong cwd
|
||||
|
||||
Launched as `pmacs ~/code/proj` from `~`, the active buffer is dired and
|
||||
pathless (§2.6), so the cwd falls through to the process cwd `~`
|
||||
(§2.7). Compile would then run in `~`, and — consistently, since §3.1
|
||||
ties them — suggest nothing.
|
||||
|
||||
**This stage does not fix it.** The fix needs a notion of "the directory
|
||||
this session is working in" that is not any one module's private table,
|
||||
which is `COHERENCE.md` §8 (First-Class Execution Locations) — a model
|
||||
gap, not wiring. Reaching into dired's `handle_for_buffer` would make
|
||||
compile depend on dired for one string and add exactly the kind of
|
||||
interaction island §6 of COHERENCE is about.
|
||||
|
||||
What this stage does guarantee is that the failure is **coherent**: the
|
||||
suggestion describes the directory the command will run in, whatever
|
||||
that directory turns out to be. The user is never offered `cargo build`
|
||||
for a directory with **no detected Cargo project**.
|
||||
|
||||
That wording is load-bearing and rev 1 got it wrong. "No `Cargo.toml`"
|
||||
would have been false on this design's own terms: a Cargo *workspace
|
||||
subdirectory* contains no `Cargo.toml`, is correctly detected as `rust`
|
||||
by the ancestor walk, and correctly receives `cargo build` — which cargo
|
||||
itself runs happily from a subdirectory. The predicate is detection, not
|
||||
the presence of a file in that one directory (§3.1).
|
||||
|
||||
N6 pins the property; it deliberately does not pin the value.
|
||||
|
||||
### 6.2 The default is per-kind, not configurable through the registry
|
||||
|
||||
`pmacs.compile.defaults` is a plain Lua table, not a registered setting.
|
||||
It cannot be one: `ConfigValue` is four scalars, so a kind→command map
|
||||
is not expressible. A scalar `compile.default-command` that overrides
|
||||
the table *is* expressible and is **deferred, not skipped** — it is one
|
||||
more precedence step and a registry entry, and it belongs with the
|
||||
config-adoption work (§20 Priority 6) rather than bolted on here.
|
||||
|
||||
### 6.3 One binding does not retire the inversion
|
||||
|
||||
`COHERENCE.md` §2 keeps a standing observation verbatim: keybinding
|
||||
coverage is inverted relative to frequency, with `C-c @ C-M-s` bound
|
||||
while opening a file, opening a terminal, and running a build were not.
|
||||
Two of the three have been answered (#162, #173); this stage answers the
|
||||
third, and §8 updates the paragraph accordingly. The *quote* stays as
|
||||
written, because it names a bias in how new work gets bound rather than
|
||||
three omissions.
|
||||
|
||||
## 7. Staging — why 1b is split
|
||||
|
||||
`COHERENCE.md` §20 bundles three items under "Stage 1b". They share a
|
||||
priority and nothing else:
|
||||
|
||||
| | subsystem | shape | risk |
|
||||
|---|---|---|---|
|
||||
| **1b-1** (this) | compile + project | wiring, Lua only | low |
|
||||
| 1b-2 | LSP lifecycle | a failure that is currently *silent* (§1.2) must become visible without becoming noise | medium |
|
||||
| 1b-3 | startup buffer | new content, plus §18's `C-h`-deletes-a-word problem | low, but touches the default keymap |
|
||||
|
||||
One feature, one branch, one PR. 1b-2 is the hard one — the silence
|
||||
asymmetry is a design question about *when* to speak, not a wiring
|
||||
question — and bundling it with a keybinding would hold the cheapest
|
||||
journey fix in the tree behind the most contested one.
|
||||
|
||||
## 8. Coherence impact
|
||||
|
||||
Per `CLAUDE.md` and `COHERENCE.md` §20's standing process change.
|
||||
|
||||
- **Journey steps touched:** 9 directly (Partial → Works); 10 indirectly
|
||||
— it was "gated entirely on step 6 or 9 succeeding first".
|
||||
- **Interaction islands:** none added. The prompt is the existing
|
||||
minibuffer; the binding joins the existing `C-c` prefix; the kind
|
||||
comes from the existing detector. `pmacs.compile.defaults` is an
|
||||
extensible table, not a new modal surface.
|
||||
- **Config registry adoption:** none, deliberately — §6.2 gives the
|
||||
mechanism reason and names the deferred scalar.
|
||||
- **Background-work attribution:** unchanged. Compile already spawns
|
||||
through the process-group machinery; this stage changes what is typed
|
||||
into the prompt, not what is spawned or how it is tracked.
|
||||
- **Doc updates riding this PR** (§25 requires it):
|
||||
- `COHERENCE.md` §2 step-9 verdict row → Works, and the
|
||||
`ProjectKind::Cargo` → `ProjectKind::Rust` correction **in both
|
||||
places** (§2 row and §20 Priority 1). §24 gains the drift entry.
|
||||
- `COHERENCE.md` §2's post-table paragraph: "Running a build still has
|
||||
no binding" → answered, with the quote itself left intact (§6.3).
|
||||
- `docs/keybindings.md`: the `C-c c` row, and the removal of the
|
||||
"no global key is assigned to them" sentence at `:191`.
|
||||
- `docs/agent-handoff.md` §1: the journey arc bullet gains Stage 1b-1.
|
||||
|
||||
## 9. Open questions for review
|
||||
|
||||
- **Q#J1 — is `C-c c` the right chord?** It is free, it is under the
|
||||
established `C-c` prefix, and it matches what most Emacs distributions
|
||||
bind compile to. The alternative worth naming is `C-c C-c`, which is
|
||||
more finger-friendly but is the chord many major modes claim
|
||||
buffer-locally, so a global one would be shadowed unpredictably later.
|
||||
- **Q#J2 — should `rust` be the only seeded default?** §3.2 argues yes
|
||||
on the grounds that a wrong prefill costs more than an empty one. The
|
||||
counter-argument is that `go build` and `make` are about as
|
||||
unambiguous as `cargo build`, and seeding them would make the table
|
||||
read as a real registry rather than a Rust special case.
|
||||
- **Q#J3 — should the prefill be selected, so typing replaces it?**
|
||||
Emacs leaves the prefill unselected and the point at the end. dired's
|
||||
prefill does the same. Matching them means "accept" is RET and
|
||||
"replace" is a kill-line first. Changing it is a minibuffer-wide
|
||||
behavior change and out of scope, but it is the ergonomic difference a
|
||||
user will notice first.
|
||||
|
||||
## 10. Ledger
|
||||
|
||||
Branch `journey-stage1b1-compile-defaults`, worktree
|
||||
`../pmacs-journey-1b1`, based on `githubsucks/main` @ `22df6ab`.
|
||||
Framing only; no code, no PR yet.
|
||||
|
||||
Recovery from a clean checkout — the two-argument form of
|
||||
`git worktree add` does not work for a remote-only branch (it fails with
|
||||
`fatal: invalid reference`, because after a bare fetch no local branch
|
||||
exists):
|
||||
|
||||
```sh
|
||||
git fetch githubsucks
|
||||
git worktree add ../pmacs-journey-1b1 \
|
||||
-b journey-stage1b1-compile-defaults \
|
||||
githubsucks/journey-stage1b1-compile-defaults
|
||||
```
|
||||
|
|
@ -187,9 +187,13 @@ Source: `builtin/runtime/compile.lua`.
|
|||
| `M-g n` / `M-g p` | `error.next` / `error.previous` — compile/grep errors when that source has claimed navigation, otherwise LSP diagnostics |
|
||||
| `` C-x ` `` | `error.next` |
|
||||
| `M-!` | `shell.command` — asynchronous output in `*shell-command*` |
|
||||
| `C-c c` | `compile.run` — prompts, prefilled from the detected project kind |
|
||||
|
||||
`compile.run` and `compile.recompile` are available through `M-x`; no
|
||||
global key is assigned to them.
|
||||
`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
|
||||
inside a repl buffer (which binds `C-c` at buffer scope); `M-x
|
||||
compile.run` still works in both.
|
||||
|
||||
## 2. Buffer-local panel keymaps
|
||||
|
||||
|
|
|
|||
|
|
@ -2750,3 +2750,106 @@ fn r5f3_tracked_line_start_matches_the_scan_across_transitions() {
|
|||
"every rewind lands at the tracked line start; buffer:\n{text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Journey Stage 1b-1 — `pmacs.compile.defaults` is public and assignable
|
||||
//
|
||||
// The table is user-writable, so a metatable with a throwing `__index`, a
|
||||
// non-string entry, and a non-table replacement all have to be
|
||||
// survivable — the same discipline `validated_rules` keeps for a hostile
|
||||
// rule container. A broken `defaults` must degrade to the pre-stage empty
|
||||
// prompt; it must never prevent compiling.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Open the compile prompt and return what it offered.
|
||||
fn compile_prompt_initial(s: &EditorState) -> String {
|
||||
exec(s, "pmacs.command.invoke('compile.run')");
|
||||
assert!(
|
||||
eval::<bool>(s, "return pmacs.minibuffer.is_active()"),
|
||||
"compile.run must open a prompt even with a hostile defaults table"
|
||||
);
|
||||
eval(s, "return pmacs.minibuffer.contents()")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j1b1_a_non_table_defaults_degrades_to_an_empty_prompt() {
|
||||
let s = editor();
|
||||
exec(&s, "pmacs.compile.defaults = 42");
|
||||
assert_eq!(compile_prompt_initial(&s), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j1b1_a_raising_defaults_index_degrades_to_an_empty_prompt() {
|
||||
let s = editor();
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.compile.defaults = setmetatable({}, {
|
||||
__index = function() error('hostile') end,
|
||||
})",
|
||||
);
|
||||
assert_eq!(compile_prompt_initial(&s), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j1b1_a_non_string_default_is_ignored() {
|
||||
let s = editor();
|
||||
exec(&s, "pmacs.compile.defaults = { rust = {}, }");
|
||||
assert_eq!(compile_prompt_initial(&s), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j1b1_a_hostile_defaults_still_lets_a_typed_command_run() {
|
||||
// The consequence that matters: degradation must not cost the user
|
||||
// the ability to compile. Asserts output produced, not a property
|
||||
// preserved.
|
||||
let mut s = editor();
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.compile.defaults = setmetatable({}, {
|
||||
__index = function() error('hostile') end,
|
||||
})",
|
||||
);
|
||||
// Through the INTERACTIVE command, so the hostile lookup actually
|
||||
// happens. Calling `pmacs.compile.run` directly never consults
|
||||
// `defaults` at all, which made an earlier draft of this pin vacuous
|
||||
// — it passed with the guard removed.
|
||||
exec(&s, "pmacs.command.invoke('compile.run')");
|
||||
assert!(
|
||||
eval::<bool>(&s, "return pmacs.minibuffer.is_active()"),
|
||||
"the prompt must still open"
|
||||
);
|
||||
exec(&s, "pmacs.minibuffer.set_contents('echo still-compiles')");
|
||||
exec(&s, "pmacs.minibuffer.accept()");
|
||||
assert!(
|
||||
pump_until(&mut s, 10_000, |s| {
|
||||
compilation_text(s).contains("still-compiles")
|
||||
}),
|
||||
"a broken defaults table must not prevent compiling; got:\n{}",
|
||||
compilation_text(&s)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j1b1_context_reports_the_cwd_a_run_would_use() {
|
||||
// `pmacs.compile.context` and `pmacs.compile.run` must answer from
|
||||
// one resolution — an explicit cwd is honoured by both, and the kind
|
||||
// is detected from that same directory.
|
||||
let s = editor();
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(dir.path().join("Cargo.toml"), b"[package]\nname=\"x\"\n").expect("write");
|
||||
exec(
|
||||
&s,
|
||||
&format!(
|
||||
"pmacs.project.set_search_boundary({:?})",
|
||||
dir.path().display().to_string()
|
||||
),
|
||||
);
|
||||
let kind: String = eval(
|
||||
&s,
|
||||
&format!(
|
||||
"return pmacs.compile.context({:?}).kind",
|
||||
dir.path().display().to_string()
|
||||
),
|
||||
);
|
||||
assert_eq!(kind, "rust", "the kind is detected from the given cwd");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1297,3 +1297,448 @@ fn preservation_display_file_still_refuses_a_directory() {
|
|||
"a refused display_file must not create a buffer"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 9 — build or test the project (Journey Stage 1b-1)
|
||||
//
|
||||
// `COHERENCE.md` §2 graded this **Partial**: `M-x compile.run` worked but
|
||||
// had no keybinding, an empty first prompt, and no `cargo build`
|
||||
// suggestion. These rows are the ratchet for the three.
|
||||
//
|
||||
// The trap these fixtures are built around: compile's last-resort cwd is
|
||||
// `std::env::current_dir()` evaluated at call time, which under `cargo
|
||||
// test` is the **pmacs repo root — itself a Cargo project**
|
||||
// (`compile_mode_acceptance.rs` pins exactly that). So any pin asserting
|
||||
// the *absence* of a Cargo suggestion must be shaped so the fallback is
|
||||
// never consulted: each fixture carries its own marker, and
|
||||
// `project_root_of_active()` therefore answers from inside the fixture.
|
||||
// `set_search_boundary` is not the defence — it clamps only a walk that
|
||||
// starts below the boundary, and the fallback's walk starts at the repo.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn ctrl(s: &mut EditorState, c: char) {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(c), KeyModifiers::CONTROL),
|
||||
);
|
||||
}
|
||||
|
||||
/// Open the compile prompt through the real `C-c c` binding.
|
||||
fn press_compile_chord(s: &mut EditorState) {
|
||||
ctrl(s, 'c');
|
||||
type_char(s, 'c');
|
||||
}
|
||||
|
||||
/// A Cargo project a journey can plausibly be run against.
|
||||
fn cargo_project() -> TempDir {
|
||||
let td = tempfile::tempdir().expect("tempdir");
|
||||
std::fs::write(
|
||||
td.path().join("Cargo.toml"),
|
||||
b"[package]\nname = \"journey-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
|
||||
)
|
||||
.expect("write Cargo.toml");
|
||||
std::fs::create_dir_all(td.path().join("src")).expect("mkdir src");
|
||||
std::fs::write(td.path().join("src/main.rs"), b"fn main() {}\n").expect("write main.rs");
|
||||
td
|
||||
}
|
||||
|
||||
/// Clamp detection to the fixture so a stray marker above the tempdir
|
||||
/// (a developer's `/tmp/.git`) cannot leak in.
|
||||
fn bound_detection_to(s: &EditorState, dir: &Path) {
|
||||
exec(
|
||||
s,
|
||||
&format!(
|
||||
"pmacs.project.set_search_boundary({:?})",
|
||||
dir.display().to_string()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The project root as **detection** reports it.
|
||||
///
|
||||
/// `pmacs.project.detect` canonicalizes before walking
|
||||
/// (`canonicalize_or_passthrough`, `src/project.rs:509-511`), so a
|
||||
/// `/var/folders/...` tempdir on macOS comes back as
|
||||
/// `/private/var/folders/...`. `canon()` is *lexical* — it never
|
||||
/// resolves symlinks — so it is the wrong expectation for any value
|
||||
/// that has passed through detection, which is exactly what the compile
|
||||
/// cwd is. Using it here failed both macOS CI legs while Ubuntu (where
|
||||
/// `/tmp` is not a symlink) stayed green.
|
||||
fn detected_root(path: &Path) -> String {
|
||||
std::fs::canonicalize(path)
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn minibuffer_active(s: &EditorState) -> bool {
|
||||
eval(s, "return pmacs.minibuffer.is_active()")
|
||||
}
|
||||
|
||||
fn minibuffer_contents(s: &EditorState) -> String {
|
||||
eval(s, "return pmacs.minibuffer.contents()")
|
||||
}
|
||||
|
||||
fn named_text(s: &EditorState, name: &str) -> String {
|
||||
eval(
|
||||
s,
|
||||
&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 ""
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Drive frames until `pred` holds, pumping the process supervisor as
|
||||
/// well as the async runtime — a compile run is a spawned child.
|
||||
fn pump_processes_until(
|
||||
s: &mut EditorState,
|
||||
timeout_ms: u64,
|
||||
mut pred: impl FnMut(&EditorState) -> bool,
|
||||
) -> bool {
|
||||
let stop = Instant::now() + Duration::from_millis(timeout_ms);
|
||||
loop {
|
||||
if pred(s) {
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= stop {
|
||||
return false;
|
||||
}
|
||||
s.tick_processes();
|
||||
s.tick_async();
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the journey to the point where a build would be run: launch on
|
||||
/// the directory, then `RET` on `name` to open a file.
|
||||
fn walk_to_open_file(dir: &Path, name: &str) -> EditorState {
|
||||
let mut s = launch(dir);
|
||||
bound_detection_to(&s, dir);
|
||||
let line = line_of(&s, name);
|
||||
exec(&s, &format!("pmacs.editor.move_to_line({line})"));
|
||||
press(&mut s, KeyCode::Enter);
|
||||
pump(&mut s);
|
||||
assert_eq!(
|
||||
active_name(&s),
|
||||
dir.join(name).display().to_string(),
|
||||
"precondition: the walk must actually open the file, or every pin \
|
||||
below is really testing the dired buffer"
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
/// **N** — `C-c c` reaches `compile.run`. Separate from N2 because a
|
||||
/// prefill assertion alone stays green if the binding is removed and the
|
||||
/// prompt is opened another way — and the binding is the thing
|
||||
/// `COHERENCE.md` says is missing.
|
||||
#[test]
|
||||
fn journey_step9_the_compile_chord_opens_the_prompt() {
|
||||
let td = cargo_project();
|
||||
let mut s = walk_to_open_file(td.path(), "Cargo.toml");
|
||||
press_compile_chord(&mut s);
|
||||
assert!(
|
||||
minibuffer_active(&s),
|
||||
"C-c c must open the compile prompt; active buffer is {}",
|
||||
active_name(&s)
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** — the prompt is prefilled from the detected project kind.
|
||||
#[test]
|
||||
fn journey_step9_the_prompt_is_prefilled_for_a_cargo_project() {
|
||||
let td = cargo_project();
|
||||
let mut s = walk_to_open_file(td.path(), "Cargo.toml");
|
||||
press_compile_chord(&mut s);
|
||||
assert_eq!(
|
||||
minibuffer_contents(&s),
|
||||
"cargo build",
|
||||
"a Rust project's first compile prompt must offer its build command"
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** — the directory the prompt captured survives a window switch.
|
||||
///
|
||||
/// The prompt is opened against A, the active buffer then moves to an
|
||||
/// unrelated directory B, and only then is the command accepted. Both
|
||||
/// readings — the header pmacs writes and the shell's own `pwd` — must
|
||||
/// say A. Falsified by dropping `{ cwd = ctx.cwd }` from `on_accept`,
|
||||
/// which is what re-resolving at accept time looks like.
|
||||
#[test]
|
||||
fn journey_step9_the_prompt_runs_in_the_directory_it_captured() {
|
||||
let a = cargo_project();
|
||||
let b = project();
|
||||
let mut s = walk_to_open_file(a.path(), "Cargo.toml");
|
||||
press_compile_chord(&mut s);
|
||||
assert!(minibuffer_active(&s), "prompt must be open against A");
|
||||
|
||||
// Move the active buffer to B while the prompt waits for input.
|
||||
exec(
|
||||
&s,
|
||||
&format!(
|
||||
"pmacs.window.display_file({:?})",
|
||||
b.path().join("alpha.txt").display().to_string()
|
||||
),
|
||||
);
|
||||
pump(&mut s);
|
||||
|
||||
// Accept a cheap command: the subject is the directory, so a fast
|
||||
// one keeps the failure message about the directory. Only the
|
||||
// editing of an already-open prompt is short-circuited; the prompt
|
||||
// itself was opened through the real chord.
|
||||
exec(&s, "pmacs.minibuffer.set_contents('pwd')");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
let finished = pump_processes_until(&mut s, 10_000, |s| {
|
||||
named_text(s, "*compilation*").contains("exited")
|
||||
});
|
||||
assert!(finished, "the accepted run must finish");
|
||||
|
||||
let text = named_text(&s, "*compilation*");
|
||||
// Detection-canonical, not lexical: the compile cwd came from
|
||||
// `pmacs.project.detect`, and `pwd` reports the physical directory.
|
||||
let a_path = detected_root(a.path());
|
||||
assert!(
|
||||
text.contains(&format!("Directory: {a_path}")),
|
||||
"the header must name the directory the prompt captured, not the newly active one;\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&a_path),
|
||||
"and `pwd` must agree with the header;\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** — the offered command runs, in the offered directory.
|
||||
///
|
||||
/// Every other pin here compares values the prompt and the resolver
|
||||
/// already agree on. A wrong directory inside `on_accept` passes all of
|
||||
/// them. This one accepts what was offered, unedited, and observes a
|
||||
/// real process.
|
||||
#[test]
|
||||
fn journey_step9_the_offered_command_builds_the_project() {
|
||||
if !binary_available("cargo") {
|
||||
assert!(
|
||||
std::env::var_os("PMACS_REQUIRE_CARGO_BUILD").is_none(),
|
||||
"PMACS_REQUIRE_CARGO_BUILD is set but `cargo` is not on PATH"
|
||||
);
|
||||
eprintln!("skipping: `cargo` is not on PATH");
|
||||
return;
|
||||
}
|
||||
let td = cargo_project();
|
||||
let mut s = walk_to_open_file(td.path(), "Cargo.toml");
|
||||
press_compile_chord(&mut s);
|
||||
assert_eq!(
|
||||
minibuffer_contents(&s),
|
||||
"cargo build",
|
||||
"precondition: the prompt offers the build command"
|
||||
);
|
||||
|
||||
// Accept exactly what was offered — no editing.
|
||||
press(&mut s, KeyCode::Enter);
|
||||
let finished = pump_processes_until(&mut s, 120_000, |s| {
|
||||
named_text(s, "*compilation*").contains("exited")
|
||||
});
|
||||
assert!(finished, "the offered build must finish");
|
||||
|
||||
let text = named_text(&s, "*compilation*");
|
||||
assert!(
|
||||
text.contains(&format!("Directory: {}", detected_root(td.path()))),
|
||||
"the build runs in the detected project root;\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("[compile exited with code 0]"),
|
||||
"and the command that was offered is one that actually runs;\n{text}"
|
||||
);
|
||||
// Positive control on the run itself. A clean exit alone does not
|
||||
// prove cargo compiled anything — this names the fixture crate, so
|
||||
// the pin fails if the offered command ever stops being a build.
|
||||
assert!(
|
||||
text.contains("journey-fixture"),
|
||||
"cargo must actually have built the fixture crate;\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** — the suggestion follows the directory the run will use.
|
||||
///
|
||||
/// A Node project nested inside a Cargo one, opened at the *inner* file.
|
||||
/// Falsified by deriving the kind from the launch directory (the Cargo
|
||||
/// root here) or from the process cwd — both yield `cargo build`.
|
||||
///
|
||||
/// It does **not** catch re-detecting from `project_root_of_active()`'s
|
||||
/// answer: that helper already returns the innermost root, so detecting
|
||||
/// from it yields `node` again and this pin stays green.
|
||||
#[test]
|
||||
fn journey_step9_a_nested_project_gets_its_own_kind_not_the_outer_one() {
|
||||
let outer = cargo_project();
|
||||
let sub = outer.path().join("sub");
|
||||
std::fs::create_dir_all(&sub).expect("mkdir sub");
|
||||
std::fs::write(sub.join("package.json"), b"{ \"name\": \"inner\" }\n").expect("write pkg");
|
||||
std::fs::write(sub.join("index.js"), b"console.log(1)\n").expect("write index.js");
|
||||
|
||||
let mut s = walk_to_open_file(&sub, "index.js");
|
||||
press_compile_chord(&mut s);
|
||||
// `minibuffer_contents` is "" both for an empty prefill and for no
|
||||
// minibuffer at all, so the emptiness assertion below is vacuous
|
||||
// without this. Bite A caught it: with the binding removed this pin
|
||||
// stayed green while six others failed.
|
||||
assert!(
|
||||
minibuffer_active(&s),
|
||||
"precondition: the prompt is actually open"
|
||||
);
|
||||
assert_eq!(
|
||||
eval::<String>(&s, "return pmacs.compile.context().kind"),
|
||||
"node",
|
||||
"the kind is detected from the resolved cwd, not the launch directory"
|
||||
);
|
||||
assert_eq!(
|
||||
minibuffer_contents(&s),
|
||||
"",
|
||||
"and `node` has no seeded default, so nothing is offered"
|
||||
);
|
||||
}
|
||||
|
||||
/// **N** — `context()` is total in a launched session.
|
||||
///
|
||||
/// Asserted with the pathless dired buffer active, so the fallback
|
||||
/// branch is the one under test. It pins a *property*, deliberately not
|
||||
/// a value: which directory the fallback resolves to is the test
|
||||
/// runner's cwd and pinning it would pin the environment.
|
||||
#[test]
|
||||
fn journey_step9_the_compile_context_is_total_even_with_no_file_open() {
|
||||
let td = cargo_project();
|
||||
let s = launch(td.path());
|
||||
assert!(
|
||||
active_name(&s).starts_with("*dired:"),
|
||||
"precondition: the pathless dired buffer is active"
|
||||
);
|
||||
assert!(
|
||||
eval::<bool>(&s, "return pmacs.compile.context().cwd ~= nil"),
|
||||
"a launched session always has somewhere to run a build"
|
||||
);
|
||||
assert!(
|
||||
eval::<bool>(
|
||||
&s,
|
||||
"local c = pmacs.compile.context()
|
||||
local ok, p = pcall(pmacs.project.detect, c.cwd)
|
||||
local detected = (ok and p) and p.kind or nil
|
||||
return c.kind == detected"
|
||||
),
|
||||
"and its kind is exactly what detection answers for that cwd"
|
||||
);
|
||||
}
|
||||
|
||||
/// **P** — `_last` still outranks the kind default.
|
||||
///
|
||||
/// Green on the pre-image (there was no default). The targeted mutation
|
||||
/// is reordering the precedence chain to put `defaults[kind]` first.
|
||||
#[test]
|
||||
fn journey_step9_preservation_the_last_command_outranks_the_default() {
|
||||
let td = cargo_project();
|
||||
let mut s = walk_to_open_file(td.path(), "Cargo.toml");
|
||||
exec(&s, "pmacs.compile.run('true')");
|
||||
let finished = pump_processes_until(&mut s, 10_000, |s| {
|
||||
named_text(s, "*compilation*").contains("exited")
|
||||
});
|
||||
assert!(finished, "precondition: a run completed and set _last");
|
||||
|
||||
press_compile_chord(&mut s);
|
||||
assert_eq!(
|
||||
minibuffer_contents(&s),
|
||||
"true",
|
||||
"a session that has compiled keeps its own command, not the project default"
|
||||
);
|
||||
}
|
||||
|
||||
/// **P** — the compile keys that already existed still dispatch.
|
||||
///
|
||||
/// Targeted mutation: writing the new binding as an unbind+bind pair
|
||||
/// over one of these sequences.
|
||||
#[test]
|
||||
fn journey_step9_preservation_the_existing_compile_bindings_survive() {
|
||||
let td = cargo_project();
|
||||
let s = walk_to_open_file(td.path(), "Cargo.toml");
|
||||
for (sequence, command) in [
|
||||
("M-g n", "error.next"),
|
||||
("M-g p", "error.previous"),
|
||||
("C-x `", "error.next"),
|
||||
("M-!", "shell.command"),
|
||||
("C-c c", "compile.run"),
|
||||
] {
|
||||
let bound: String = eval(
|
||||
&s,
|
||||
&format!("return pmacs.keymap.lookup({sequence:?}).command"),
|
||||
);
|
||||
assert_eq!(
|
||||
bound, command,
|
||||
"{sequence} must still dispatch to {command}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `name` resolves on PATH — the fixture-dependency gate the
|
||||
/// process suite uses, so a missing binary skips rather than fails.
|
||||
fn binary_available(name: &str) -> bool {
|
||||
std::process::Command::new("which")
|
||||
.arg(name)
|
||||
.output()
|
||||
.is_ok_and(|o| o.status.success())
|
||||
}
|
||||
|
||||
/// **N** — the compile directory is *detection*-canonical, not lexical.
|
||||
///
|
||||
/// This pin exists because its absence broke both macOS CI legs while
|
||||
/// Ubuntu stayed green: `/var` is a symlink to `/private/var` there, so
|
||||
/// a tempdir's lexical and canonical paths differ, and the original
|
||||
/// assertions used the lexical one.
|
||||
///
|
||||
/// Reproducing it on Linux needs an explicit symlink — which is also a
|
||||
/// real configuration `Workspace::detect`'s own doc comment names
|
||||
/// ("`/tmp/sandbox/foo` symlinked to `/home/user/code/foo`"). Launching
|
||||
/// through the link makes the two paths disagree on every platform, so
|
||||
/// the regression can no longer hide behind a filesystem that happens
|
||||
/// not to use symlinks.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn journey_step9_the_compile_directory_is_detection_canonical() {
|
||||
let parent = tempfile::tempdir().expect("tempdir");
|
||||
let real = parent.path().join("real");
|
||||
std::fs::create_dir_all(real.join("src")).expect("mkdir real");
|
||||
std::fs::write(
|
||||
real.join("Cargo.toml"),
|
||||
b"[package]\nname = \"journey-fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
|
||||
)
|
||||
.expect("write Cargo.toml");
|
||||
std::fs::write(real.join("src/main.rs"), b"fn main() {}\n").expect("write main.rs");
|
||||
|
||||
let link = parent.path().join("link");
|
||||
std::os::unix::fs::symlink(&real, &link).expect("symlink");
|
||||
|
||||
// Precondition, or the pin is vacuous: the two spellings must
|
||||
// actually differ, which is the whole condition macOS creates for
|
||||
// free and Linux does not.
|
||||
assert_ne!(
|
||||
canon(&link),
|
||||
detected_root(&link),
|
||||
"the fixture must make lexical and canonical paths disagree"
|
||||
);
|
||||
|
||||
let mut s = walk_to_open_file(&link, "Cargo.toml");
|
||||
press_compile_chord(&mut s);
|
||||
exec(&s, "pmacs.minibuffer.set_contents('pwd')");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
let finished = pump_processes_until(&mut s, 10_000, |s| {
|
||||
named_text(s, "*compilation*").contains("exited")
|
||||
});
|
||||
assert!(finished, "the accepted run must finish");
|
||||
|
||||
let text = named_text(&s, "*compilation*");
|
||||
assert!(
|
||||
text.contains(&format!("Directory: {}", detected_root(&link))),
|
||||
"the header must name the directory detection resolved to;\n{text}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue