aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/design/module-inventory.org2
-rw-r--r--docs/design/vamp-music-player.org340
-rw-r--r--docs/native-comp-subr-mocking.org159
3 files changed, 500 insertions, 1 deletions
diff --git a/docs/design/module-inventory.org b/docs/design/module-inventory.org
index eeb824b57..fb883d701 100644
--- a/docs/design/module-inventory.org
+++ b/docs/design/module-inventory.org
@@ -205,7 +205,7 @@ flyspell-and-abbrev is the one Core-UX member (text-mode hooks).
| =eshell-config= | 3 | D/P | eager | command | system-utils | add-hook, advice-add, package config | yes |
| =eww-config= | 3 | D/P | eager | command | cl-lib | package config | yes |
| =flyspell-and-abbrev= | 2 | C/P | eager | hook | cl-lib | mode-hook package config | yes |
-| =games-config= | 4 | O | eager | command | none | package config | yes |
+| =games-config= | 4 | O | command | command | user-constants | package config | yes |
| =gloss-config= | 4 | O/D/P | eager | command | none | package config | yes |
| =httpd-config= | 4 | O/D/P | eager | command | none | package config | yes |
| =jumper= | 4 | O/L | eager | command | cl-lib | jumper keymap | yes |
diff --git a/docs/design/vamp-music-player.org b/docs/design/vamp-music-player.org
new file mode 100644
index 000000000..12b92443b
--- /dev/null
+++ b/docs/design/vamp-music-player.org
@@ -0,0 +1,340 @@
+#+TITLE: Design: VAMP — a standalone Emacs music player
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-22
+#+OPTIONS: toc:nil num:nil
+
+Status: Draft
+
+VAMP = "VAMP Audio Music Player" (recursive backronym; /vamp/ is itself a
+musical term — a short repeated passage). Namespace =vamp-=, repo =~/code/vamp=.
+
+This design came out of a 2026-06-22 brainstorm. It supersedes parts of the
+earlier EMMS-removal work and confirms others — see "Relationship to Prior
+Work" below.
+
+* Problem
+
+=modules/music-config.el= (925 lines) is an EMMS configuration layer welded
+into =.emacs.d=: it mixes genuinely reusable logic (M3U management, fuzzy add,
+random-history navigation, radio-station creation, consume mode) with personal
+config (ncmpcpp-aligned keybindings, paths, dashboard wiring), and it depends
+on the EMMS package for its playlist model, player backend, and track info.
+The goal is a standalone, publishable Emacs music player — derived from a
+maintained subset of EMMS, depending on the EMMS package not at all — that
+Craig uses as his primary player, launchable from Hyprland like dirvish.
+
+* Relationship to Prior Work
+
+A spec and a detailed review already exist and remain partly authoritative:
+
+- =docs/specs/music-config-without-emms-spec.org= — the EMMS-removal spec.
+- =docs/design/music-config-without-emms-review.org= — third-pass review
+ (2026-05-15) with a go/no-go and a 14-item decision punch list.
+
+This brainstorm *confirms* four of that review's decisions, independently
+re-derived: long-running MPV + JSON IPC from day one (B1); a state-change hook
+contract firing STARTED/STOPPED/PAUSED/RESUMED/FINISHED (B2); a fake-backend
+testutil with an events ledger (B4); metadata via MPV IPC on the STARTED event
+(S3).
+
+It *pivots* the direction in four ways the prior work assumed otherwise:
+
+- Publishable from the start (old spec: personal-first, public "later").
+- Two adapters — MPV and mpd — behind a generalized adapter API (old: MPV-only,
+ a single backend protocol). This is the largest change; it turns their single
+ "backend protocol" into a real multi-backend seam.
+- Name VAMP (old candidate: =cadenza=).
+- Desktop integration as a first-class concern: a Hyprland Super+/ launcher, a
+ daemon-singleton instance model, q-closes-frame-while-playback-continues, and
+ an m3u MIME association — none of which the prior work addressed.
+
+The prior review's cross-platform decision is absorbed unchanged: Linux + macOS
+ship full-feature; Windows is best-effort (play/stop/next/previous only) per
+Craig's 2026-05-15 call.
+
+Next step (tracked below): revise the spec to this direction before
+=/start-work=.
+
+* Non-Goals
+
+- No music-library database, tag index, or browser UI (light metadata only).
+- No mid-track position resume on backend switch (v1 re-cues from track start).
+- No persisted session state across daemon restarts (M3U save/load is the only
+ way a playlist comes back).
+- The package does not install OS wiring — the Hyprland bind, the launcher
+ script, window rules, the =.desktop= file, and =xdg-mime= defaults all live
+ in archsetup.
+- No full tag-reading in v1 (deferred to the first post-v1 enhancement).
+
+* Assumptions
+
+Researched facts (verified this session):
+
+- EMMS is GPLv3+ (read from =emms.el=); any code derived from it makes VAMP
+ GPLv3+. Fine for MELPA, which prefers GPL.
+- The EMMS core subset VAMP would draw from is ~6–7k lines: =emms.el= (1741),
+ =emms-playlist-mode.el= (685), =emms-player-mpv.el= (772),
+ =emms-player-mpd.el= (1367), the M3U sources (~800), native tag readers
+ (~1080), playing-time (258). The ~16k excluded surface is browser, filters,
+ tag-editor, lyrics, mpris, scrobblers, musicbrainz.
+- The dirvish-popup / quick-capture launcher pattern (emacsclient named frame +
+ Hyprland window rules + q-to-close, single-instance focus-existing) is the
+ established model on Craig's machine.
+- mpd is installed and running; Craig will use it to test the second adapter.
+
+Assumptions to confirm before/early in build:
+
+- mpd driven as a "dumb" single-file player (clear queue → add one file → play
+ → idle for end-of-track) behaves cleanly. mpd is designed to own a queue;
+ the dumb-player contract must be validated against real mpd behavior.
+- The m3u XDG MIME association works on Craig's exact Hyprland/xdg setup
+ (mechanism is standard; prototype the =.desktop= early per Craig's request).
+
+* Approaches Considered
+
+** Recommended: B/A hybrid — clean core, ported adapter internals
+
+Write a small, fresh playlist/playback/navigation core with an adapter API of
+VAMP's own design (B); port only the fiddly MPV-IPC and mpd-protocol internals
+from EMMS as reference (A), since that protocol handling is the hard-won part
+not worth reinventing. The core owns the queue and all play-modes; adapters are
+thin single-file players.
+
+Pros: a small core Craig fully understands and can maintain solo; a clean
+adapter API shaped for the two-backend goal; reuses EMMS's proven IPC/protocol
+code without inheriting its whole design.
+
+Cons: GPLv3+ (from the ported adapter code); real upfront design effort on the
+core + adapter API before any feature lands; risk of missing subtle
+player-process lifecycle behavior EMMS already handles.
+
+What it trades away: the option of a non-GPL license, and a fast feature-first
+start.
+
+** Rejected: vendor-and-trim (A alone)
+
+Copy the ~8 core EMMS files, delete the rest, renamespace, keep EMMS's backend
+pattern as VAMP's own. Fastest to feature-complete, but inherits 6–7k lines of
+someone else's idioms to "maintain yourself" — works against the maintainability
+goal that motivated the project.
+
+** Rejected: thin core, delegate to backends (C)
+
+Lean hard on the backend (mpd owns its queue; MPV gets a minimal one). Least
+code, but backend asymmetry leaks into inconsistent behavior and fat adapters —
+and the brainstorm chose core-owns-queue precisely for uniform behavior and
+seamless backend switching. C survives only as an adapter-capability detail
+(let mpd do server-side work as a future optimization).
+
+** Rejected: wrap existing client libraries (D)
+
+Build on mpdel + mpv.el as a thin UX layer. Directly contradicts the
+"depend on nothing / maintain it myself" goal.
+
+** Rejected: MPRIS/D-Bus as the one universal adapter (E)
+
+Drive any MPRIS player over D-Bus. "Many players" almost free, but MPRIS is
+control-only — it can't reliably own playlists or load arbitrary files across
+players. Kept in the back pocket as a possible future adapter class, not a v1
+foundation.
+
+** Rejected: external daemon + JSON-RPC (F)
+
+Move player logic to an external process, Emacs as thin client. Ships a
+non-Elisp component — packaging burden, not a pure-Elisp MELPA package. Overkill
+for local playback.
+
+* Design
+
+** Architecture
+
+Standalone repo at =~/code/vamp=, Eask-based like pearl (Eask, Makefile,
+autoloads, =tests/=, README, LICENSE — GPLv3+). Three layers:
+
+- *Core* (backend-agnostic, owns all stateful logic): the queue model (track
+ list, current index, play-modes — shuffle, repeat-playlist, repeat-track,
+ random + history ring, consume); the playback controller (orchestrates
+ load + play on the current track, handles end-of-track, advances per mode);
+ sources (add files/dirs/recursive, URLs, M3U load/save/clear/reload/edit;
+ radio-station creation); the playlist-mode buffer + window toggle/show;
+ light metadata.
+- *Adapter layer* (the extensibility seam): a =cl-defgeneric= protocol every
+ backend implements. Ships with MPV (spawned subprocess + JSON IPC socket) and
+ mpd (daemon connection, driven as a dumb single-file player).
+- *=.emacs.d= glue* (=vamp-config.el=): keybindings (C-; m map, playlist-mode
+ keys), music-root path, dashboard wiring, customize values. No logic.
+
+Three-project split: VAMP ships the elisp + entry points; =.emacs.d= keeps
+keybindings/glue; archsetup owns the OS wiring (Super+/ bind, launcher script,
+window rules, =.desktop=, =xdg-mime=).
+
+** Adapter API
+
+A backend is a class implementing generic methods. The contract is deliberately
+narrow (transport + metadata), because the core owns the queue and modes:
+
+- =load-file= — load a track URL/path (do not advance anything)
+- =play= / =pause= / =stop=
+- =seek= — to an absolute or relative position
+- =position= — current playback position
+- =report-metadata= — title/artist/album/duration the backend knows about
+- an *end-of-track notification* — each adapter translates its native
+ "track finished" signal (MPV: the IPC =end-file= event; mpd: the idle
+ =player= subsystem) into one uniform core callback
+
+This is the review's B2 state-change contract, generalized across backends. A
+new backend is a new class + method implementations; nothing in the core
+changes.
+
+** Backend switching
+
+The payoff of core-owns-queue: the queue and current track are backend-agnostic
+state in the core, so a runtime switch is just — stop the outgoing adapter
+(kill the MPV subprocess / drop the mpd connection), set the active adapter,
+and the next play re-issues =load-file= to the new backend on the same current
+track. Nothing in the queue moves; the selected song stays selected. An
+interactive =vamp-switch-backend= command (completing-read over self-registered
+adapters) is bound under C-; m and in playlist-mode. v1 re-cues from track
+start on switch; mid-track position-resume is a post-v1 addition (the contract
+already has =seek=, so it's additive).
+
+** Data flow / control loop
+
+A user action (play / next / previous) updates queue state in the core (current
+index advanced per the active play-mode), then the core calls the active
+adapter's =load-file= + =play=. End-of-track is the one hard cross-backend
+signal: the adapter fires the uniform callback, and the core's handler consults
+the play-mode and advances — repeat-track replays, repeat-playlist wraps, random
+pushes history and picks next, consume drops the finished track, normal advances
+or stops at the end. A track is a struct (url/path + type slot + cached light
+metadata); the queue is an ordered track list + current index + mode flags + the
+random-history ring.
+
+** Presentation / faces
+
+Every stateful UI surface gets a named =defface=, so status is shown by face,
+not hardcoded color: playlist current/played/consumed lines and metadata
+columns; play state (playing/paused/stopped); each play-mode with a lit (on) and
+dimmed (off) face; the active-backend indicator (MPV vs mpd); backend
+health (e.g. mpd-disconnected as an error face). These render in a header-line
+status strip in the playlist buffer (and feed the mode-line); the mode/transport
+indicators light via their on-face and dim via their off-face.
+
+Base palette: faces ship with defaults that *inherit from standard Emacs faces*
+(=success=, =warning=, =error=, =shadow=, =highlight=, =font-lock-*=) so they
+look right under any user theme out of the box and adapt automatically. A
+separate, optional =vamp-theme.el= carries the opinionated palette. Every face
+stays individually overridable. The selected-track line uses a single reused
+overlay repositioned on each STARTED event (review B3).
+
+Testing the palette: because they're standard deffaces, theme studio (the
+=.emacs.d= tool) renders them directly — load the VAMP faces, preview the
+playlist buffer + status strip, check legibility against the modus contrast
+targets, iterate.
+
+** Desktop integration + instance model
+
+Launcher: a =vamp-popup= script (mirror of dirvish-popup) bound to Super+/ in
+Hyprland; ncmpcpp moves to Shift+Super+/. The script focuses an existing
+"vamp" frame if one is open, else spawns a floating frame running
+=(vamp-popup)=; Hyprland window rules float/size/center the "vamp"-named frame.
+
+Instance model: one player instance = the daemon's global state (queue, active
+adapter, the live MPV subprocess / mpd connection). Super+/ attaches a view
+frame to it. q closes that frame but *playback continues in the daemon* — close
+the window, music plays on, reopen to see it again. A separate command (or Q)
+fully stops and tears down the player. The launcher's focus-existing behavior
+enforces an at-most-one view frame, so there are no competing instances. The
+non-daemon case (standalone Emacs) is its own instance — an edge case, since
+Craig runs the daemon.
+
+m3u MIME association: a =.desktop= file with
+=MimeType=audio/x-mpegurl;audio/mpegurl;application/x-mpegurl;application/vnd.apple.mpegurl=
+and =Exec=music-open %f= (wrapper → emacsclient … =(vamp-open-m3u "%f")=), then
+=xdg-mime default=. Opening any =.m3u= from a file manager or =xdg-open= then
+launches/raises VAMP and loads that playlist. The package only needs the
+=vamp-open-m3u FILE= entry point; the =.desktop= + =xdg-mime= live in archsetup.
+
+** Persistence
+
+Playlists: M3U save/load/clear/reload/edit, file-based, same as today. No
+session state — each daemon start is empty (today's behavior).
+
+** Metadata
+
+v1: adapter-reported metadata for the playing track only (MPV =get_property
+metadata= on STARTED; mpd reports tags from its DB). The playlist shows
+filename/path-derived labels (today's =track-description= behavior); the current
+track shows the real title/duration the backend reports. Post-v1: vendor
+=emms-info-native= (~1080 lines; mp3/ogg/flac) for real artist/album tags across
+the whole playlist, which is what unlocks sort-by-tag.
+
+** Error handling
+
+Failures surface via =user-error= / =message=, never silently — the
+music-config history (the silent Slack-notify and lock-screen bugs) is the
+cautionary tale. A missing/dead backend (mpd not running, mpv binary absent)
+reports clearly and is reflected in the header-line health face.
+
+** Testing
+
+The adapter is the system boundary (subprocess / IPC / network), so that is the
+only thing mocked — never the core. A *test adapter* (null backend, review B4's
+=testutil-music-backend.el=) implements the protocol, records
+=load-file=/=play=/=stop= calls in an events ledger, and lets a test fire the
+end-of-track callback on demand. With it, the entire control loop and every
+play-mode is testable as pure logic — no MPV, no mpd, no audio. This is
+dependency-injection rather than primitive-mocking, which also sidesteps the
+native-comp subr-mock trap the suite recently fought (see
+=docs/native-comp-subr-mocking.org=): a fake adapter is injected, not a subr
+=cl-letf='d. Core-logic tests (queue, navigation, M3U parse/write, fuzzy add,
+source expansion) are largely the existing ~193 music-config tests, ported with
+renamed symbols. Per-adapter tests mock the IPC socket / protocol connection and
+assert the native-event → uniform-callback translation. One or two integration
+tests drive the real core through the test adapter.
+
+** Observability
+
+A debug log buffer captures raw adapter I/O (the IPC/protocol traffic) for
+diagnosing backend issues (EMMS has this for mpv; worth keeping). State changes
+surface in the header-line + mode-line. A =vamp-doctor= command reports backend
+availability and, on Windows, the degraded-mode limitation.
+
+** Cross-platform stance
+
+Linux + macOS ship full-feature (IPC over unix domain sockets). Windows is
+best-effort — play/stop/next/previous only, no pause/seek/volume — via
+=start-process= + stdin or one-shot =call-process=, because Emacs's
+=make-network-process= doesn't natively support Windows named pipes. Documented
+in the README and surfaced by =vamp-doctor=. (Craig, 2026-05-15.)
+
+* Open Questions
+
+- [ ] mpd dumb-single-file-player contract — validate that clear-queue → add →
+ play → idle-for-end behaves cleanly against real mpd; decide the exact command
+ sequence. Candidate for an early spike.
+- [ ] Exact mpd end-of-track signal handling (idle =player= vs polling) and how
+ it maps to the uniform callback without races.
+- [ ] =.desktop= + =xdg-mime= prototype on Craig's Hyprland setup — confirm m3u
+ opens VAMP early (Craig asked to de-risk this first).
+- [ ] Floating-frame geometry / Hyprland window rules for the "vamp" frame
+ (archsetup detail).
+- [ ] v1 parity catalog — confirm the 13 EMMS features from the review's S1 all
+ carry into the playlist-mode keymap (seek, volume, one-shot shuffle, info,
+ center, kill-track, bury, append-to-M3U, active-window tint, dired/dirvish
+ add).
+
+* Next Steps
+
+- *Reconcile the spec.* Revise =docs/specs/music-config-without-emms-spec.org=
+ to this direction — publishable-now, two adapters + generalized adapter API,
+ VAMP name, desktop integration + instance model — keeping the review's
+ confirmed B1/B2/B4/S3 decisions and the 14-item punch list still relevant.
+- *Spike the risky assumptions* (the two mpd open questions, the m3u =.desktop=)
+ before committing the adapter API shape.
+- Open questions that are genuine decisions → =arch-decide= as ADRs.
+- Implementation → =/start-work= against the revised spec; pure-helper
+ extraction (review's Migration Plan step 1) is the safe first phase and can
+ start independently.
+- Link this doc from the =todo.org= task "Extract music-config into a standalone
+ plugin."
diff --git a/docs/native-comp-subr-mocking.org b/docs/native-comp-subr-mocking.org
new file mode 100644
index 000000000..f66e5d102
--- /dev/null
+++ b/docs/native-comp-subr-mocking.org
@@ -0,0 +1,159 @@
+#+TITLE: Native Compilation vs. Mocking C Primitives in Tests
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-21
+
+* What this is
+
+A reference for a real, recurring trap: tests that redefine an Emacs C
+primitive (a "subr") with =cl-letf=, =fset=, =setf=, or =advice-add= behave
+differently once native compilation is enabled, and the failures are
+intermittent. We hit it head-on after re-enabling native-comp config-wide
+(early-init.el, commit 3fd28987, 2026-06-20). This document records the
+mechanism, the research, and the decision so we don't re-derive it.
+
+* The symptom
+
+After native-comp was re-enabled, tests that had been green for months started
+failing, with no change to their source. The errors looked like:
+
+: wrong-number-of-arguments #[nil (nil) (t)] 1
+
+That is a zero-argument mock lambda being called with one argument. The 8 tests
+that first tripped were in =test-dirvish-config-wrappers.el= and
+=test-calibredb-epub-config.el=, all mocking window primitives
+(=current-window-configuration=, =window-body-width=, =window-margins=,
+=get-buffer-window=).
+
+The failures were intermittent across the session: the same test passed, then
+crashed, then passed again. That non-determinism is the tell.
+
+* The mechanism
+
+Native-comp emits *direct* calls to primitives for speed. So when Lisp code
+redefines or advises a primitive (which is exactly what a test mock does),
+natively-compiled callers would normally bypass the redefinition entirely. To
+prevent that, Emacs generates a small per-primitive *trampoline* (a =.eln=
+under =eln-cache/=) the first time a primitive is redefined. The trampoline
+reroutes calls to the primitive through its Lisp function cell, where the mock
+lives.
+
+The trampoline is generated lazily and cached on disk, and that is the source
+of the non-determinism: whether a given mock "works" depends on whether the
+trampoline for that primitive has been compiled into the eln-cache yet. As
+native-comp compiles more in the background, more mocks start routing through
+trampolines.
+
+** Three distinct failure modes
+
+Because behavior depends on trampoline state, the same mock can fail three
+different ways:
+
+1. *Generation failure.* The trampoline =.eln= can't be built or loaded
+ (notably under =emacs --batch=), giving
+ =native-lisp-load-failed "... subr--trampoline-*.eln"=. This is the mode our
+ older CLAUDE.md insight first documented.
+2. *Silent bypass.* When a trampoline isn't available and can't be generated,
+ the manual states natively-compiled callers *ignore* the redefinition and
+ call the real primitive. The mock does nothing, so the test passes for the
+ wrong reason or asserts against real behavior.
+3. *Arity mismatch.* The trampoline *is* built and routes to the mock, but
+ calls it with the primitive's *maximum* arity (filling optionals with nil),
+ not the arity the source used. A fixed-arity mock narrower than the
+ primitive then throws =wrong-number-of-arguments=. This is the mode that bit
+ us this session (every one of the 8 was this).
+
+* Important: this is a test-only artifact
+
+Production code never redefines a C primitive, so these trampolines are never
+generated for this reason in normal use. Nothing here is a defect in the
+config. It is an incompatibility between *mocking primitives in tests* and
+native-comp, confined to the test suite.
+
+* What the wider community has found
+
+This is well known and genuinely hard. It is not us doing something wrong.
+
+- [[https://lists.gnu.org/archive/html/bug-gnu-emacs/2021-10/msg00971.html][bug#51140 (emacs-devel)]] — "cl-letf appears not to work with native-comp."
+ Redefining a built-in like =process-exit-status= via =cl-letf= breaks under
+ native compilation. Confirms the core problem.
+- [[https://github.com/jorgenschaefer/emacs-buttercup/issues/230][buttercup issue #230]] — the buttercup test framework's =spy-on= on primitives
+ (=file-exists-p=, =buffer-file-name=) fails with the
+ =native-lisp-load-failed ... subr--trampoline-*.eln= error (failure mode 1).
+ Our scenario exactly, in a mainstream test framework.
+- [[https://groups.google.com/g/linux.debian.bugs.dist/c/n9P2xhpruDE][Debian bug#1021842]] — buttercup's *own self-tests* hit the trampoline
+ compilation error. Even the test framework's maintainers run into it.
+- [[https://lists.gnu.org/archive/html/bug-gnu-emacs/2023-03/msg00076.html][bug#61880 (emacs-devel)]] — native compilation fails to generate trampolines
+ in certain sequential cases (failure mode 1, deterministic variant).
+- [[https://lists.gnu.org/archive/html/emacs-diffs/2023-03/msg00145.html][emacs-29 commit (bug-fix)]] — Emacs added a warning when you redefine a
+ primitive that the trampoline machinery itself depends on
+ ("Redefining '%s' might break trampoline native compilation"). Shows the
+ maintainers' stance: redefining primitives is discouraged.
+- [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Native_002dCompilation-Variables.html][ELisp Manual: Native-Compilation Variables]] — documents
+ =native-comp-enable-subr-trampolines=. Default on; generates trampolines on
+ the fly. When *off* and no cached trampoline exists, "calls to that primitive
+ from natively-compiled Lisp will ignore redefinitions and advices" (this is
+ failure mode 2, and the catch in the common workaround below).
+
+** The two commonly-cited workarounds, and their costs
+
+- *Disable subr trampolines for tests* (=native-comp-enable-subr-trampolines
+ nil=). The most-cited quick fix. One line. But per the manual it makes
+ natively-compiled callers *ignore* the mock (failure mode 2). It only works
+ reliably when the code under test runs interpreted, not natively compiled.
+ With native-comp aggressively compiling our modules, the code under test is
+ increasingly native, so this risks silent mock-bypass: tests that pass while
+ asserting against the real primitive. Worse than a loud failure.
+- *Don't mock primitives at all.* The maintainers' and our own
+ =elisp-testing.md='s position: inject dependencies or test pure helpers
+ instead. The only fix immune to all three failure modes. Also the most work.
+
+* Our decision (2026-06-21)
+
+We chose a pragmatic middle path with a clear long-term direction.
+
+1. *Make subr mocks variadic.* The arity mode (3) is the only one we have
+ actually suffered. A mock written =(lambda (&rest _) VALUE)= tolerates the
+ trampoline's full-arity call. We swept every arity-narrow subr mock in the
+ suite to append =&rest _= to its arglist (preserving any named args the
+ body uses). This is deterministic and keeps trampolines on, so mocks still
+ route correctly (no silent bypass).
+2. *Enforce it with a meta-test.* =tests/test-meta-subr-mock-arity.el= statically
+ scans every test file for =symbol-function= / =fset= redefinitions of a
+ subr and fails =make test= if any mock can't accept the primitive's maximum
+ arity (=func-arity=). It is deterministic (a pure source read; no dependence
+ on eln-cache state), so a new arity-narrow mock can't merge silently. The
+ rule it enforces is NOT "never mock a subr" (the suite mocks subrs like
+ =message= and =completing-read= hundreds of times, all fine) but "a subr
+ mock must accept the primitive's arity."
+3. *Treat "migrate off primitive-mocking" as a long-term test-quality project.*
+ The variadic sweep fixes the mode we hit but leaves modes 1 and 2 latent
+ (we haven't hit them, but they exist). The durable fix the ecosystem points
+ to is restructuring tests to not redefine primitives at all. Filed as a
+ standalone TODO rather than forced now.
+
+** Why not just disable trampolines for tests?
+
+Because of failure mode 2 (silent bypass) above. In our native-comp-heavy
+setup, disabling trampolines would let natively-compiled code under test ignore
+the mocks, producing tests that pass while testing nothing. A loud
+=wrong-number-of-arguments= that the meta-test prevents up front is strictly
+safer than a quiet false pass.
+
+* Practical rule for writing tests (today)
+
+When you mock a C primitive (subr) in a test, make the replacement variadic:
+
+: (cl-letf (((symbol-function 'window-body-width) (lambda (&rest _) 200)))
+: ...)
+
+not
+
+: (cl-letf (((symbol-function 'window-body-width) (lambda (_) 200))) ; breaks under native-comp
+: ...)
+
+If the body needs the argument, keep it and append =&rest _=:
+
+: (lambda (cmd &rest _) (member cmd allowed))
+
+The meta-test will catch you if you forget. Better still, when practical, don't
+mock the primitive: pass the value in as a parameter, or test a pure helper.