aboutsummaryrefslogtreecommitdiff
path: root/CLAUDE.md
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-24 00:15:26 -0400
committerCraig Jennings <c@cjennings.net>2026-06-24 00:15:26 -0400
commit02db8729485e86323141c4d1e6676438dbe6dc9f (patch)
treebd6e1af5446778a06e160acd7d96caadb343b101 /CLAUDE.md
parent34cb16f6dd3c06d07f3d74b2a49aa22ed0b50bd5 (diff)
downloaddotemacs-02db8729485e86323141c4d1e6676438dbe6dc9f.tar.gz
dotemacs-02db8729485e86323141c4d1e6676438dbe6dc9f.zip
docs: codify the lexical-binding foreign-special-var compile trap
Record the coverage-core failure: let-binding json.el's reader vars in a lexical-binding file without a compile-time defvar makes the compiled code bind them lexically, so they never reach json-read-file. Same compiled-vs-interpreted class as the existing make-test and native-comp insights. Claude-Session: https://claude.ai/code/session_01BqrdWUo9GcznYX2pZr76gZ
Diffstat (limited to 'CLAUDE.md')
-rw-r--r--CLAUDE.md2
1 files changed, 2 insertions, 0 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 8a13334c7..0e193ce5c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -93,3 +93,5 @@ Prefer Write over cumulative Edits for nontrivial new code. Small functions (und
- **`make test` runs with no `package-initialize` — defuns inside a `use-package :config` are void there.** The Makefile's `EMACS_TEST` is `emacs --batch --no-site-file --no-site-lisp` with no `package-initialize`, so elpa packages never load and a `use-package` block whose package isn't found never runs its `:config`. Any `defun` nested inside that `:config` is unbound under `make test` / `make test-file`. The per-edit PostToolUse hook *does* initialize packages, so such defuns load there — a test can pass on save under the hook yet fail `make test`. To unit-test logic that lives in a `:config` block, extract it into a top-level defun outside `use-package` (the `cj/dwim-shell--empty-dirs-command` / `cj/dwim-shell--dated-backup-command` pattern) and test that; keybindings or mode-wiring that must stay in `:config` get live-daemon verification instead. (`gotcha` — 2026-06-13)
- **Mocking a C primitive (subr) in a test is fragile under native-comp; if you must, make the mock variadic — `(lambda (&rest _) ...)`.** When a test redefines a primitive (`cl-letf`/`fset`/`setf`/`advice-add`), native-comp routes natively-compiled callers through a per-primitive trampoline `.eln`, and that interaction fails three different ways depending on eln-cache state: (1) the trampoline `.eln` fails to build/load under `--batch` (`native-lisp-load-failed ... subr--trampoline-*.eln`); (2) when no trampoline is available the redefinition is *silently ignored* and native callers run the real primitive (a quiet false pass); (3) the trampoline calls the mock with the primitive's *maximum* arity, so a fixed-arity mock narrower than the primitive throws `wrong-number-of-arguments`. Mode 3 is the common one — a `(lambda (_) 200)` mock of `window-body-width` (a 0-2-arg subr) gets called with 2 args. Note many routinely-mocked functions are subrs (`message`, `completing-read`, `y-or-n-p`, `executable-find`, `save-buffer`, `byte-compile-file`), and those are fine *because* they're mocked variadically; the trap is the narrow fixed-arity ones. The rule, enforced by `tests/test-meta-subr-mock-arity.el` (fails `make test` on any arity-narrow subr mock): a subr mock must accept the primitive's max arity, so append `&rest _` (keep named args the body uses: `(lambda (cmd &rest _) ...)`). The durable fix the ecosystem and our own `elisp-testing.md` point to is *don't mock the primitive*: drive real state (a `make-temp-file` fixture, `insert`/`set-buffer-modified-p`) or extract a pure helper and test that. Full mechanism, the three modes, research, and decision: [[file:docs/native-comp-subr-mocking.org][docs/native-comp-subr-mocking.org]]. (`gotcha` — refined 2026-06-21 after re-enabling native-comp surfaced 170 latent arity-narrow mocks)
+
+- **`let`-binding another package's dynamic var in a `lexical-binding` file needs that var declared special at compile time, or the compiled code binds it lexically and silently no-ops.** `coverage-core.el` let-bound `json-object-type` / `json-array-type` / `json-key-type` around `json-read-file` to get string-keyed hash tables, with `(require 'json)` *inside* the function. Interpreted, that worked: the runtime require ran first, made the vars special, so the `let` bound them dynamically. Byte/native-compiled, the compiler had never seen json.el's `defvar`s (the require is a runtime form), so under `lexical-binding: t` it compiled them as *lexical* locals that never reach `json-read-file` — which then returned json.el's default symbol-keyed alist, and the parser's `maphash` over it signaled `wrong-type-argument hash-table-p`. The tell: passes interpreted / in the daemon, fails under `make test` (which loads the `.elc`). Same class as the existing "make test has no package-initialize" and native-comp gotchas — the compiled path diverges from the interpreted one. Fix: make the defvars visible to the compiler with `(eval-when-compile (require 'json))` at top level (or a bare `(defvar json-object-type)` for each), keeping the runtime `(require 'json)` so json stays off the load-time path. General rule: never `let`/`setq` a foreign special var in a lexical file without a compile-time `defvar`/`require` for it. (`gotcha` — 2026-06-24)