Emacs mechanisms whose documented behaviour differs from what their names suggest. Elisp syntax, defun,
let, pcase, and the package catalogue are assumed; every rule below is a place where correct-looking code
is silently wrong.
Baseline choices
Emacs 30.x is the baseline; defer to the active package set for the exact point release.
lexical-binding: tin the file header, always.- eglot for LSP: built-in since 29 and the recommended default.
- Tree-sitter
*-ts-modevariants where available (29+, improved in 30.2). - The modern completion stack: vertico, orderless, marginalia, consult, corfu, cape.
use-packageis built-in since 29; prefer:customoversetq,:hookoveradd-hook, and defer with:defer/:commands/:hook.#'function-namefor function references, so the byte-compiler can warn.- Namespace every symbol with the package prefix;
provideat end of file.
Tree-sitter modes: fboundp is insufficient
On 29.1+ the *-ts-mode functions are autoloaded built-ins, so their symbols are always fboundp
regardless of whether the grammar shared library is installed. (fboundp 'json-ts-mode) returns non-nil even
when activating the mode fails with "language grammar for LANG is unavailable". fboundp answers "is this
mode defined?", not "can this mode run?"
Use treesit-language-available-p, which takes a language symbol rather than a mode symbol, so a
mode-to-language mapping is required, because the language name is not always the mode prefix
(js-ts-mode → javascript). treesit-ready-p is the higher-level form that also emits the standard
user-facing warning.
(defvar my-ts-mode-language-alist
'((json-ts-mode . json)
(js-ts-mode . javascript)
(python-ts-mode . python)))
(defun my-ts-mode-available-p (mode)
(when-let ((lang (alist-get mode my-ts-mode-language-alist)))
(and (fboundp mode) (treesit-language-available-p lang))))
When the mapping is maintained separately from the dispatch list, the two tables drift: a new ts-mode is added to the dispatch list, its language is never registered, the availability check silently returns nil, and the mode is never selected. Assert the sync in a unit test so a silent runtime fallthrough becomes a deterministic failure.
Keymaps
Asserting contents
lookup-key and where-is-internal are lossy for test assertions. For a key sequence that is only a
prefix of a longer binding, lookup-key returns an integer (the number of events consumed), which is easy to
misread as "bound to something". Bindings inside a composed keymap or a nested prefix keymap can also be missed
depending on how the lookup is issued.
For composed or prefix-heavy keymaps, walk the raw structure with map-keymap, descending into nested keymaps
yourself:
(defun my-keymap-commands (keymap)
(let (acc)
(map-keymap
(lambda (_event binding)
(cond ((keymapp binding) (setq acc (append acc (my-keymap-commands binding))))
((commandp binding) (push binding acc))))
keymap)
acc))
Named function keys need angle brackets. (kbd "<left>") returns [left], which is what keymaps store.
(kbd "[left]") returns the six literal characters, and because [ is itself a self-inserting prefix,
lookup-key can return an integer partial-match that disguises the mistake.
Code dispatching via call-interactively requires the target to satisfy commandp. A test replacing a real
command with a plain lambda gets (wrong-type-argument commandp ...); give mock lambdas an (interactive)
form.
A mode's keymap and its entry point are frequently in different files: the keymap via defvar-keymap in
the main feature file, helper commands in a sibling. A test requiring only the helper feature observes an
unbound or empty keymap and asserts against nothing. Require the feature that actually defines the keymap.
Precedence
Emacs consults keymaps in a fixed order, and the major-mode map sits near the bottom: overriding and
terminal-local maps, then emulation-mode-map-alists, then minor-mode-overriding-map-alist, then
minor-mode-map-alist, then the local (major-mode) map, then global. Any package installing bindings through
the minor-mode layers (every modal editing package does) shadows a major-mode map unconditionally,
however that map is populated. A feature that must see raw keys cannot achieve it with a major-mode keymap.
Install bindings that must outrank other minor modes through emulation-mode-map-alists, and re-assert your
entry at the head of that list every time your mode is enabled, not once at load time, because a package
enabled later pushes its own entry ahead of yours. minor-mode-overriding-map-alist is the buffer-local
equivalent and the smaller hammer.
Never mutate a shared keymap for local state. The map created by define-minor-mode or bound by
defvar-keymap is a single global object reached by every buffer using that mode. Mutating it to reflect
buffer-local state (most commonly set-keymap-parent to select an input mode or layout) changes the
effective bindings of every other buffer using that mode, and the symptom appears far from the cause.
Compose per buffer instead:
(use-local-map (make-composed-keymap buffer-specific-map shared-mode-map))
Composition allocates a fresh object per buffer and leaves the shared map untouched. This is the keymap instance of a wider rule (buffer-local state belongs in an object the buffer owns), which also governs overlays versus text properties below.
Verification lies
A stale .elc masks the source
With both LIB.el and LIB.elc on the same load-path entry, load uses the .elc even when the .el is
newer, emitting only a warning that is easy to miss in batch output. A .eln beats .elc beats .el. So a
stale .elc hides a source fix: a passing test does not prove the patch works, and a failing test may not
reflect current source.
find . -name '*.elc' -delete
emacs -Q --batch --eval '(setq load-prefer-newer t)' \
-L . -L test -l ert -l my-feature -l my-feature-test \
-f ert-run-tests-batch-and-exit
Better still, byte-compile to a temporary destination so verification never leaves .elc in the tree. If a
result contradicts a source change, suspect stale bytecode first.
Candidate order beats timestamp
load-prefer-newer chooses between .el and .elc within a single directory. It says nothing about which
directory is consulted first. When the same feature exists in both the worktree and an installed location (a
Nix site-lisp path, a package.el tree), the first matching candidate in load-path wins regardless of
modification time, so an installed .elc shadows the source you just edited even with load-prefer-newer set.
In one investigation, ten of fifteen apparent test failures were this loader false negative rather than a
regression.
Place every worktree source directory ahead of any installed location explicitly, then prove provenance:
(symbol-file 'my-feature-function) reports where a definition was actually loaded from, and
(locate-library "my-feature") reports which candidate the loader would pick. Deleting .elc and setting
load-prefer-newer does not settle the multi-candidate case.
Compilation removes the seam you stubbed
cl-letf on a function cell intercepts only calls that go through that cell, and two byte-compiler behaviours
route around it silently. The compiler lowers many primitives to dedicated opcodes: a compiled caller of
set, setcar, setcdr, car, cdr, or aref emits the opcode and never consults the function cell. And a
defsubst is inlined into its compiled callers, so a stub on its own symbol is never consulted. In both cases
the stub installs without complaint and the real implementation runs: a green result for an injection that
never happened, or a red one attributed to the wrong cause.
Inject faults at a named, non-inlinable boundary you own: put the primitive mutation behind an ordinary
defun, or expose an injectable hook variable. Never promote a function tests rely on as a seam to
defsubst for speed. Assert the injected fault actually fired (a counter, a sentinel) rather than only the
downstream outcome, and run that assertion in both the interpreted and byte-compiled configurations, because
the interpreted run is the one where the stub does work.
Other compilation traps
Cross-file macros. Macros expand at compile time in the calling file. Recompiling only the
macro-defining file leaves the call site carrying an old expansion, failing with invalid-function or calling
a stale expansion. Treat a macro's callers as part of its compilation unit.
Batch load-path completeness. A batch run fails at load time, before any test executes, if a required
feature's directory is absent. Transitive requires matter: pass one -L per directory contributing a required
feature, including test-support helpers.
Macro expansion shape. A defun-generating macro can expand to (defalias NAME #'(lambda ...)) rather
than a literal defun, and macroexpand is a top-level contract only: it may expand the outermost macro into
a progn while leaving nested calls inside let untouched. Normalize to a canonical shape before asserting.
Warning suppression has specific correct forms. Under byte-compile-error-on-warn, a runtime
(boundp 'other-package-var) guard does not suppress the free-variable warning, because the compiler sees
the direct reference inside the guarded branch whether or not it runs. Read an optional late-bound global
through (symbol-value 'other-package-var) after the boundp check, but use symbol-value only for optional
data access; when the variable is a genuine cross-module mutation contract, declare it with a value-less
(defvar other-package-var) so the contract stays visible. On Emacs 29 a many-slot cl-defstruct generates a
constructor docstring exceeding the width limit, and with-suppressed-warnings ((docstrings) ...) does not
suppress it there: wrap only the offending form in with-no-warnings.
Dynamic modules
The loaded artifact is usually not the built artifact. A module's load path is typically hardcoded to an install prefix rather than the build output, so a successful build does not imply the running Emacs sees the new code. A stale installed library keeps executing code deleted from the source, surfacing as doubled effects, rendering artifacts, or empty output, with no error anywhere. Compare modification times of built and installed artifacts as the first check, not the last.
On macOS, copying a dynamic library invalidates its ad-hoc signature. AMFI refuses the load and the kernel
kills the process: Emacs dies with SIGKILL, exit 137, no Lisp error, no backtrace, nothing naming the
module. Re-sign after any copy: codesign --force --sign - /path/to/module.dylib. Treat status 137 on module
load as a signature problem until proven otherwise.
Autoload cookies
loaddefs-generate copies the form after a ;;;###autoload cookie verbatim into loaddefs, except for a
fixed set it converts into safe autoload calls: defun, defmacro, cl-defun, cl-defmacro,
define-overloadable-function. Put a cookie before anything else (a custom macro invocation, a side-effecting
top-level form) and the whole form is copied raw and executed at load time, running side effects
unconditionally and possibly failing if the macro is not yet defined.
Place a bare cookie only before a recognized definition form. To autoload a name produced by a custom macro, write the explicit form yourself:
;;;###autoload (autoload 'my-command "my-file")
Installing from source compiles your tests. With package-vc or use-package :vc, the package manager may
traverse and byte-compile test/. On Emacs 30.x, .elpaignore and :ignored-files ("test/") do not reliably
stop package--compile descending into tests, so compilation fails on files requiring unavailable helpers. A
repo-side approach that has worked: test/.dir-locals.el binding ((emacs-lisp-mode . ((no-byte-compile . t)))).
Verify against your target version rather than assuming.
Macro hygiene
A defmacro needs three protections Elisp does not give automatically. A helper function the macro calls
during expansion must exist at compile time, which means wrapping it in eval-and-compile; a plain defun
is not evaluated while the calling file is being byte-compiled, so expansion fails with void-function at
compile time rather than at runtime (the cross-file recompilation trap above is the sibling failure of the same
root cause). Every symbol the macro introduces that the caller did not write must be uninterned: prefer
(cl-gensym "prefix-"), whose counter suffix keeps repeated expansions distinguishable in a backtrace, over
(make-symbol "prefix"), which returns exactly the name given, so every call site shares one print name. And
Elisp derives neither indentation nor Edebug support from a macro's lambda list: a macro taking a body argument
needs (declare (indent N) (debug FORM)) as its first form, or callers get flat default indentation and no
step-debugging into the body. Finally, a macro must never evaluate a caller-supplied argument form more than
once or reorder its evaluation relative to other arguments: bind each one exactly once through a gensym'd
let before referencing it, since cl-lib has no once-only helper to reach for.
Lifecycle and error boundaries
A cleanup or teardown boundary is a rollback. Two ownership rules extend the condition-handling discipline in the next section. A teardown loop must isolate per-item failure rather than aborting on the first one, since an aborted loop leaves every later resource fully live (held, registered, retained) while the caller's aggregate state already reports "shut down," with no remaining owner positioned to retry. And restoring state recovers values, not the identity of any reference a caller already holds: a caller that captured a handle before the transaction still observes the mutated original through that handle even after "rollback" completes, unless the restore mutates the original in place.
quit is not an error
C-g signals quit, and quit is not a subtype of error. A condition-case naming only error does
not run for a user interrupt: the non-local exit passes straight through and skips whatever the handler was
going to do. Because C-g is a routine user action, this supposedly rare path is common, and an adversarially
injected quit walks out of a cleanup helper instead of making it fail closed.
Every cleanup, teardown, or fault-isolating boundary handles both error and quit: capture the first
condition, keep running every remaining step, then re-signal the captured condition unchanged (same symbol,
same payload) so callers observe the original failure rather than a cleanup artefact.
(defun my-run-cleanup-steps (steps)
(let ((inhibit-quit t) primary)
(dolist (step steps)
(condition-case err
(funcall step)
((error quit) (unless primary (setq primary err)))))
(when primary (signal (car primary) (cdr primary)))))
Bind inhibit-quit around the whole restoration when a second pending C-g could interrupt it.
A major-mode change erases buffer-local state
Changing a buffer's major mode calls kill-all-local-variables, which runs change-major-mode-hook first
and erases buffer-local bindings afterwards. A buffer-local minor mode recording its resources (overlays,
markers, timers, processes, registry entries) in buffer-local variables loses the handle the moment the user
types M-x fundamental-mode. Its disable command never runs, kill-buffer-hook never runs because the
buffer is still alive, and a global disable command can no longer discover the orphaned resources. Two
independent packages have hit this the same way.
Register a buffer-local change-major-mode-hook entry calling one shared teardown (the same one the disable
command and kill-buffer-hook call) so resources are released while local state still exists. Make it
idempotent, because all three paths can fire for the same buffer.
(define-minor-mode my-feature-mode
"Toggle My Feature in the current buffer."
:lighter " MyF"
(if my-feature-mode
(progn
(add-hook 'change-major-mode-hook #'my-feature--teardown nil t)
(add-hook 'kill-buffer-hook #'my-feature--teardown nil t))
(remove-hook 'change-major-mode-hook #'my-feature--teardown t)
(remove-hook 'kill-buffer-hook #'my-feature--teardown t)
(my-feature--teardown)))
Isolate hook observers with run-hook-wrapped
A hook variable is not a plain list. Its buffer-local value may contain the sentinel t, which splices the
global value in at that position. Two mistakes follow: wrapping the aggregate run-hooks call in a
condition-case isolates the hook as a whole, so the first observer that signals prevents every later one
from running and can skip required follow-up such as cache invalidation; and hand-rolling the traversal with
dolist loses the t splice and the local/global merge entirely, so buffer-local or global observers
silently stop being called.
run-hook-wrapped performs the standard traversal while calling a wrapper around each individual function. Put
the condition-case inside the wrapper, return nil so traversal continues, demote only the errors the hook
documents as suppressible, and never swallow quit.
(defun my-feature--call-observer (fn &rest args)
(condition-case err
(apply fn args)
(error (message "observer %S failed: %S" fn err)))
nil)
(run-hook-wrapped 'my-feature-after-change-hook #'my-feature--call-observer buffer)
Variable watchers are not a stack
add-variable-watcher prepends while remove-variable-watcher deletes destructively, so replaying a saved
list front to back reverses the effective order. Batch-verified on 30.2 and 31, and checked against the 29
implementation. Snapshot (copy-sequence (get-variable-watchers SYMBOL)) together with the bound-or-unbound
state and the value; on restore, remove every watcher currently present including any the body installed,
restore the value or unbound state, then re-add the saved watchers in reverse order.
Installing an anonymous lambda as a watcher makes it unremovable by identity, so every module reload
accumulates another copy, the same trap as a lambda in a hook, with no remove-hook-style escape. Always
install a stable named function and remove it before re-adding.
Buffer state and editing contracts
Overlays are owned; text properties are not
Text properties live in the buffer text, so they are shared with every indirect buffer made from the same
base: applying read-only or cursor-intangible in an indirect buffer makes the base read-only too, even
though the buffer-local variable tracking that decoration is not shared and the base has no record of it. Text
properties also have no notion of an owner (two features writing the same property over overlapping ranges
are indistinguishable) so a feature that captures the previous value and restores it later silently discards
whatever a concurrent writer added. That has been reproduced as real state loss.
Overlays are the opposite: each belongs to exactly one buffer, is not shared with indirect buffers, and is a first-class object you delete by identity. Use overlays for decoration your feature owns and must remove exactly. Reserve text properties for attributes that genuinely belong to the text. Never implement ownership as "record the old value of a shared property and put it back later".
Overlay hooks have endpoint gaps
An overlay's modification-hooks, insert-in-front-hooks, and insert-behind-hooks do not fire for
insertions at absolute buffer endpoints outside the overlay, and a zero-width overlay inherits the same hole
because there is no interior for a change to land in. A guard built only from overlay hooks therefore permits
edits at point-min and point-max, and cached marker bounds refreshed after a boundary edit drift out of
sync with overlays that were never repositioned.
Back an overlay-based guard with buffer-local before-change-functions and after-change-functions, keep the
authoritative bounds in markers, and resynchronize the overlays from those markers after each change.
Region and atomic-change contracts
(interactive "r") supplies point and mark whenever a mark exists at all (it does not require the region
to be active) so a command declared with "r" will cheerfully transform a stale range the user does not
believe is selected. Guard with an explicit use-region-p check.
atomic-change-group reverts buffer text on a non-local exit but does not restore point or mark, so an
error or C-g partway through leaves the cursor and region somewhere the user did not put them. For a
destructive region edit: compute the replacement before touching the buffer, save point and mark including the
region's direction, perform the delete-and-insert inside atomic-change-group, and restore point and mark
yourself on both the error and the quit path.
insert is not self-insert-command
A great deal of behaviour hangs off post-self-insert-hook: electric-pair-mode, electric-indent-mode,
auto-fill, abbrev expansion, many minor modes. insert does not run that hook. Code typing characters on
the user's behalf therefore disables all of it silently, with no error and no visible cause.
(defun my-insert-char (char n)
(let ((last-command-event char))
(self-insert-command n char)))
self-insert-command and its observers read the character from last-command-event, not from an argument.
Data structure hazards
Mutating a stored key orphans the entry. A hash table computes a key's hash once, at insertion. Under
equal the key is compared structurally, so a mutable key the caller destructively modifies after puthash
no longer hashes to the bucket its entry sits in. The entry becomes unreachable under both old and new key
while the physical entry remains: logical and physical size diverge, and repeated put-then-mutate grows the
table without bound. A reproducer with capacity one and twenty iterations ended with logical size one and
physical size twenty.
A public API accepting a caller-owned mutable value as a key must detach it before storing:
copy-sequence for a string or vector, a deep copy for a structured key. Where keys may be cyclic, register a
dedicated test with define-hash-table-test backed by sxhash-equal and a cycle-safe comparison: on Emacs 30
the built-in equal test can signal circular-list on cyclic cons keys. Unbounded growth of a table whose
logical size stays small is the diagnostic signature.
Recursion bounded by collection length. Elisp recursion is bounded by max-lisp-eval-depth, and that
ceiling is reached by ordinary data. A recursive walk whose depth tracks the length of a collection (the
characters of a long key, the entries of a bucket, the elements of a list being copied) passes every small
test and fails once real input arrives: a scale-dependent failure with no reproducible trigger. Recursion
bounded by structural nesting is a different case and is generally fine.
Rewrite length-tracking recursion as an explicit loop over a worklist. To copy a mutable object graph that may
contain cycles and shared structure, use a two-phase traversal with an eq-keyed memo: first walk
allocating and memoizing one empty shell per mutable object while discovering children, then walk the memo
connecting edges. This preserves cycles and shared identity in O(V+E) at constant call depth.
An absent stamp is not a comparable value. A cache validated by "the recorded stamp still equals the
current stamp" degenerates into "always valid" whenever the stamp function returns nil for an absent input. If
the stamp is a sentinel file's mtime from file-attributes, a missing sentinel yields nil, the entry stores
nil, and (equal nil nil) reports a hit forever. The scheme is correct exactly when the sentinel exists,
the case the author tested. Treat an unobtainable stamp as a miss and refuse to store an entry whose validity
token is nil.
Processes and remote paths
with-timeout does not bound a synchronous call. It schedules a timer, and timers fire only when Emacs
reaches its event loop. A synchronous call-process never returns to the event loop, so Emacs simply stays
blocked: the timeout appears to work only because the helper usually returns quickly. The same call
accumulates unbounded stdout and returns an exit status easy to discard by accident.
Run any helper that could hang under make-process, with one decrementing wait budget shared by startup and
draining, a byte-counted cap on accumulated stdout, a separate non-accumulating destination for stderr, and an
explicit check that the exit status was zero before believing the output.
The exit sentinel can precede pending output. A sentinel reporting termination does not mean output has
been delivered: output the child already wrote may still be pending in the filter. A stress probe of a helper
writing 32 bytes lost stdout in 12 of 20 runs, and a test against it fails intermittently in a full suite
while passing alone. After the sentinel fires, keep draining with accept-process-output until the process is
no longer live and no further output arrives. Read an intermittent truncation that appears only under load as
a drain race, not as flakiness to retry away.
The wait budget is shared by startup and drain. accept-process-output's TIMEOUT bounds a single slice, so
any aggregate bound is the caller's to maintain: as a decrementing budget, never an absolute deadline
recomputed from float-time.
(defun my-drain (proc budget)
(unless (and (numberp budget) (> budget 0) (< budget 1.0e+INF))
(error "Invalid wait budget: %S" budget))
(let ((remaining budget) (iterations 0))
(while (and (process-live-p proc)
(> remaining 0)
(< (setq iterations (1+ iterations)) 10000))
(let ((slice (min 0.05 remaining)))
(accept-process-output proc slice nil t) ; JUST-THIS-ONE: established process
(setq remaining (- remaining slice))))))
Validate the budget and reject degenerate values (non-numeric, NaN, non-finite, zero, negative) rather than clamping silently, and cap the iteration count so a slice returning immediately cannot spin.
JUST-THIS-ONE is asymmetric. It suppresses processing of other processes' events, which is what you want
while draining a response body: it stops unrelated filters running re-entrantly mid-read. It is wrong
while waiting for a :nowait connection to be established, because the connection-completion event goes
through the same machinery: pinning attention to that one process can prevent Emacs ever observing that it
connected. Leave it nil when awaiting establishment; pass it non-nil when reading from an established process.
Process-tree cleanup needs identity. A PID is not an identity: the OS reuses PIDs, so a routine recording
a PID and signalling it later can signal an unrelated process. Descendants make it worse: a helper that forks
and exits immediately leaves a setsid child reparented away before any process-table scan sees it,
reproduced in 10 of 10 attempts. And a scan bounded for memory reaches its cap as saturation, which is
not the same as having enumerated everything: treating the cap as completion silently orphans the remainder.
Identify by the pair of PID and immutable start time, re-verified immediately before and after stopping. The safe sequence is SIGSTOP, re-verify identity, then SIGKILL only confirmed-stopped identities, so a recycled PID can never be killed. Close the reparent race with a cryptographically opaque ownership token in the child's environment, scanned for immediately after launch as well as at cleanup, rather than relying on parentage. Report a saturated scan and any signal-delivery failure as incomplete cleanup; never fold either into a success. Use a monotonic clock for the deadline, and spool large output to a bounded temporary file.
Remote paths block; subprocesses do not follow. Emacs file-name primitives are remote-transparent:
file-exists-p, file-attributes, and directory-files on a remote path go over the network and can block
for a full remote-access timeout, freezing the UI during what looked like local bookkeeping. Subprocess
primitives are not symmetrically transparent (shell-command-to-string and call-process run locally
regardless of a remote default-directory) so a helper invoked to inspect "the project" inspects the wrong
machine and returns confidently wrong metadata. Two unrelated packages have hit one side each.
Guard bulk filesystem work with (and (file-exists-p path) (not (file-remote-p path))). When a subprocess must
run where the directory lives, use process-file and start-file-process. When the tool genuinely exists only
locally, detect file-remote-p and decline rather than returning local results for a remote tree.
Untrusted input
The general discipline belongs to trust-boundaries; these are Emacs APIs that look inert and are not.
Opening a file can execute it. Visiting a file applies its file-local variables, and a file-local eval:
entry is code chosen by whoever wrote the file. Any package opening a path derived from outside the user's own
intent (a request handled by an in-Emacs server, a link inside rendered content, an entry from a search index)
hands arbitrary code execution to whoever controls that file, at the moment of preview.
(let ((enable-local-variables nil)
(enable-local-eval nil))
(with-temp-buffer
(insert-file-contents path)
(my-render-preview)))
Decode-then-validate is not enough. url-unhex-string is a decoder, not a validator, and it fails open in
two directions: it normalizes some sequences (%0d%0a can emerge as spaces) so a check on the decoded string
never sees the CRLF that was in the input, which is the classic header-injection bypass; and it preserves
malformed triplets like %ZZ verbatim rather than rejecting them. Validate the raw encoded form first,
rejecting percent-encoded control characters and malformed triplets, then decode, then validate again. The
pre-decode check is the one usually missing.
Strings carry text properties into the display. An Elisp string is not a leaf value: it can carry
display, keymap, local-map, and face, any of which change what the user sees and what their keys do. A
string from a data file, a network response, or persisted state can carry any of them, and ordinary operations
preserve them: concat keeps them, format propagates them from a %s operand into its result, and
propertize adds a property rather than replacing the set, so applying your own outer face does not remove
an attacker-selected keymap. Copying does not help: copy-tree does not copy strings, so a deep copy
still shares the propertized objects inside.
At the presentation boundary, replace untrusted text with a clean copy via substring-no-properties, then
apply your own properties to that copy. Leave the original intact where semantic properties matter internally.
This applies to every untrusted operand passed through the format call, not only the one you were thinking
about.
Testability
Extract output across a macro boundary. Output produced through with-help-window is awkward to test:
assertions have to stub the macro, coupling tests to expansion details. Extract the rendering into a helper
that writes into the current buffer and keep the public command a thin wrapper. Tests then call the helper
inside with-temp-buffer. The seam is where side-effecting presentation meets pure content generation.
Isolate feature-local macros into a sibling *-macros.el, with runtime functions in the original file
which requires it. This makes load order explicit and shrinks the feature file.
A family of near-identical commands invites a parallel data table describing them, which becomes a second
source of truth that drifts. Define the family with a declarative defmacro and delete the table once the
macro invocations are its only consumers.
Contributing upstream
Discover a project's conventions from its own artifacts rather than guessing:
- Commit style: CONTRIBUTING plus
git logfor the actual norm. - Changelog: the file and its format, including symbol-quoting conventions.
- Naming: the private/public prefix split (
pkg--vspkg-). - Test harness: how tests run (
make test,eask,ert-runner), the file layout, tags used to skip environment-specific cases, and the mocking idiom (commonlycl-letfonsymbol-function). - Compatibility gate: the minimum Emacs version, the CI matrix, and whether byte-compilation is treated as an error.
- Formatting commits: whether whitespace-only changes must be separate and recorded in
.git-blame-ignore-revs.
Publishing to MELPA means keeping the recipe, the main library's headers, and the local lint/compile targets in agreement with what package-build actually generates rather than with the working tree verbatim.
- Recipe: a single Lisp form in the archive's
recipes/directory, named exactly after the package ((foo :fetcher github :repo "owner/foo")). A submission is a pull request adding only that file, built against the current repository HEAD. - Version: computed, not the
Version:header you wrote. The unstable channel is stamped from the date of the latest commit touching a selected file, the stable channel from a matching SCM tag parsed byversion-to-list. The header is a floor that package-lint and package.el read literally, so it must still be present and monotonic. - Generated files: never commit
NAME-pkg.elor autoloads. Anything package-build generates and you also commit either gets overwritten or ships stale. :files: the exact boundary of what an installed user receives. The default set already covers top-level andlisp/*.elplus docs and excludes test files, so most packages need no override at all.- package-lint / checkdoc: package-lint checks that the declared
emacsminimum inPackage-Requiresmatches the newest built-in symbol the code actually calls, and that every defined symbol carries the package prefix. checkdoc governs docstring form: imperative-mood first sentence,`symbol'quoting. - Release gate: must not mutate the contributor's tree or personal package directory. Byte-compile and
validate generated autoloads in a temporary directory, and never infer success from
git diffagainst ignored generated files.
Related
- trust-boundaries: the general untrusted-input discipline
- test-integrity: proving which implementation a test actually loaded
- testing-patterns: seam design and fixture isolation