aboutsummaryrefslogtreecommitdiff
path: root/docs/design
diff options
context:
space:
mode:
Diffstat (limited to 'docs/design')
-rw-r--r--docs/design/2026-07-10-curated-help-menu-survey.org96
-rw-r--r--docs/design/2026-07-10-ledger-config-audit.org146
-rw-r--r--docs/design/2026-07-10-org-module-ownership.org138
-rw-r--r--docs/design/signal-client-review.org2
-rw-r--r--docs/design/subr-mock-migration.org158
-rw-r--r--docs/design/utility-inventory.org6
6 files changed, 542 insertions, 4 deletions
diff --git a/docs/design/2026-07-10-curated-help-menu-survey.org b/docs/design/2026-07-10-curated-help-menu-survey.org
new file mode 100644
index 00000000..a7469ac1
--- /dev/null
+++ b/docs/design/2026-07-10-curated-help-menu-survey.org
@@ -0,0 +1,96 @@
+#+TITLE: Survey — where a curated "?" help menu makes sense
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-10
+
+* What this is
+
+The mechanical half of the "=?= = curated help menu" convention task. It reports,
+per mode, whether =?= is free, whether =H= is free, and what the package already
+ships. It changes no keybinding and makes no per-mode recommendation, because which
+commands belong in a curated menu is a taste call.
+
+The reference implementation is calibredb (=modules/calibredb-epub-config.el:129-131=):
+=?= runs =cj/calibredb-menu=, a curated transient of the frequent workflows, and =H=
+runs the package's own =calibredb-dispatch=.
+
+* Method
+
+Bindings were read from the live keymaps, not from source, because a package's
+=define-key= calls and its =:bind= forms don't always agree with what ends up in the
+map.
+
+The probe ran in a throwaway =emacs --batch= with =package-initialize=, not in the
+running daemon, for two reasons. Probing =calibredb-search-mode-map= or
+=mu4e-headers-mode-map= in the daemon would force-load those packages into a live
+session. And a batch Emacs reports the *package's* defaults rather than the defaults
+plus Craig's config, which is the question being asked: what does the package ship?
+
+=mu4e= is not an ELPA package. It installs with =mu= into
+=/usr/share/emacs/site-lisp/mu4e=, so it needed an explicit =load-path= entry.
+
+* The table
+
+=FREE= means the key is unbound in that mode's map.
+
+| Mode | =?= | =H= | Ships a dispatch? |
+|---------------+--------------------------------+---------------------------+------------------------|
+| calibredb | =cj/calibredb-menu= (curated) | =calibredb-dispatch= | yes, moved to =H= |
+|---------------+--------------------------------+---------------------------+------------------------|
+| nov | FREE | FREE | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+| eat | FREE | FREE | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+| elfeed-search | FREE | FREE | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+| elfeed-show | FREE | FREE | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+| dired/dirvish | =dired-summary= | =dired-do-hardlink= | no (summary is a |
+| | | | one-line echo) |
+|---------------+--------------------------------+---------------------------+------------------------|
+| magit-status | =magit-dispatch= | =magit-describe-section= | yes, already on =?= |
+|---------------+--------------------------------+---------------------------+------------------------|
+| pdf-view | =describe-mode= | =pdf-view-fit-height-...= | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+| org-agenda | =org-agenda-show-the-flagging- | =org-agenda-holidays= | no |
+| | note= | | |
+|---------------+--------------------------------+---------------------------+------------------------|
+| mu4e-headers | =mu4e-headers-mark-for-unread= | =mu4e-display-manual= | no |
+|---------------+--------------------------------+---------------------------+------------------------|
+
+* What the table shows
+
+Four modes have both keys free: nov, eat, elfeed-search, elfeed-show. Nothing has to
+be displaced to adopt the convention there. These are the cheap ones.
+
+Three modes bind =?= to something small. =dired-summary= echoes a one-line key
+reminder. =pdf-view= sends =?= to =describe-mode=. =org-agenda= puts a
+flagging-note command on it. In each case the convention would displace a command of
+low value, but it is a displacement and needs a decision.
+
+=mu4e-headers= is the awkward one. Its =?= is =mu4e-headers-mark-for-unread=, a real
+editing command in the marking family (=u=, =d=, =!=). Rebinding it would break
+muscle memory for an operation that mutates mail state. Its =H= is already
+=mu4e-display-manual=.
+
+=magit= is the counterexample worth noting. It already does exactly what the
+convention proposes: =?= opens =magit-dispatch=, its own curated transient. The
+convention is not new, it is magit's habit, generalized. Magit needs no change.
+
+=dirvish= inherits =dired-mode-map=, so a change there lands on plain dired too.
+
+* Caveats
+
+The probe reads package defaults. Craig's own config may rebind =?= in a mode after
+the package loads, and the batch probe would not see it. A grep for =?=-rebinding in
+=modules/*.el= found only the calibredb case, so the table should hold, but a mode
+whose config binds =?= inside a =:config= block reached by a path the grep missed
+would not show up.
+
+=signel= and =pearl= are in the task's candidate list and are absent from the table.
+Neither was probeable: they are local checkouts, not ELPA packages, and loading them
+in batch pulls in dependencies the probe deliberately avoided.
+
+* The decisions this leaves
+
+For each mode, whether a curated =?= menu is worth its cost, and if so, which
+commands go in it. That is the taste call, and the reason this survey stops here.
diff --git a/docs/design/2026-07-10-ledger-config-audit.org b/docs/design/2026-07-10-ledger-config-audit.org
new file mode 100644
index 00000000..ff969d78
--- /dev/null
+++ b/docs/design/2026-07-10-ledger-config-audit.org
@@ -0,0 +1,146 @@
+#+TITLE: Ledger config audit — correctness and guardrail gaps
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-10
+
+* Scope
+
+The correctness half of the ledger guardrail work. It audits
+=modules/ledger-config.el= (71 lines, no test file before today) and reports what's
+wrong and what's missing. It designs no guardrails, because those choices are
+Craig's.
+
+Characterization tests for the behavior that exists now landed alongside this note
+at =tests/test-ledger-config.el=.
+
+The stakes set the bar. This is a financial file, and Craig's stated goal for the
+follow-on work is "enough guardrails that it's hard to make a costly mistake."
+
+* Findings
+
+** F1. The linting doesn't run. Nothing checks unbalanced transactions.
+
+FIXED 2026-07-10. =flycheck-config.el= now hooks =flycheck-mode= to =ledger-mode=.
+Verified end to end on an unbalanced fixture: flycheck reports "Transaction does not
+balance" with the =$10.00= remainder. The finding is kept below as the record of
+what was wrong.
+
+The module's own commentary says it provides "flycheck linting."
+
+=flycheck-ledger= is loaded (=ledger-config.el:59-60=) and registers the =ledger=
+checker. But =flycheck-mode= is never enabled in a ledger buffer.
+=flycheck-config.el:47= hooks =flycheck-mode= to exactly two modes:
+
+#+begin_src elisp
+:hook ((sh-mode emacs-lisp-mode) . flycheck-mode)
+#+end_src
+
+There is no =global-flycheck-mode= anywhere in the config. Verified against a real
+=ledger-mode= buffer in the running daemon: =major-mode= is =ledger-mode=,
+=flycheck-checkers= contains =ledger=, and =flycheck-mode= is =nil=.
+
+So an unbalanced transaction, a malformed date, and a typo'd account name all
+produce no warning. The guardrail Craig believes he has is not connected.
+
+This is the finding that matters. Everything below is smaller.
+
+** F2. Every save silently reorders the entire file.
+
+=cj/ledger--clean-before-save= runs =ledger-mode-clean-buffer= on
+=before-save-hook= (=ledger-config.el:32-40=), with =cj/ledger-clean-on-save=
+defaulting to =t=.
+
+=ledger-mode-clean-buffer= (=ledger-mode.el:226=) is not a formatter. Its own
+docstring says "Indent, remove multiple line feeds and sort the buffer," and its
+body calls, in order: =untabify=, =ledger-sort-buffer=, =ledger-post-align-postings=,
+=ledger-mode-remove-extra-lines=.
+
+=ledger-sort-buffer= (=ledger-sort.el:106=) sorts the whole buffer by date, from
+=point-min= to =point-max= unless the file carries explicit sort markers.
+
+So every =C-x C-s= rewrites the transaction order of a financial file. The task
+asked whether clean-on-save "ever reorders or rewrites in a surprising way." It
+reorders, every time, by design of the upstream function.
+
+*Accepted 2026-07-10, on inspection of the sort key.* The reordering is
+chronological and nothing else. =ledger-sort-startkey= (=ledger-sort.el:62=) builds
+its key from the first ten characters of a transaction's opening line, the ISO date,
+through =ledger-parse-iso-date= and =float-time=. Payee, amount, and account never
+enter the key. =sort-subr= uses Emacs's stable sort, so transactions sharing a date
+keep the order they were typed in.
+
+The scope is bounded on request: =ledger-sort-buffer= narrows to the region between
+=; Ledger-mode: Start sort= and =; Ledger-mode: End sort= marker comments when the
+file carries them, and only falls back to the whole buffer when it doesn't.
+
+So a save sorts by date, stably, over a region the file can choose. Craig accepts
+that. The finding stays recorded because "clean-on-save" still reads as whitespace
+tidying, and the next person to meet this function should know it sorts.
+
+** F3. The demoted error hides a partial rewrite, not just a message.
+
+The clean is wrapped in =with-demoted-errors= so "a malformed buffer still saves"
+(=ledger-config.el:34-37=).
+
+The comment is accurate about the save. It is misleading about the buffer. The
+operations inside =ledger-mode-clean-buffer= run in sequence and mutate as they go.
+An error raised by =ledger-post-align-postings= or by the =search-forward= that
+restores point (=ledger-mode.el:239=) happens *after* =ledger-sort-buffer= has
+already reordered everything. Nothing rolls back.
+
+The result is a file that saved, in a state neither the user nor the cleaner
+intended, with a message in the echo area that scrolls away.
+
+** F4. Reconcile has no confirmation, and clears whole transactions.
+
+=ledger-clear-whole-transactions= is =t= (=ledger-config.el:43=), so a reconcile
+marks entire transactions cleared rather than individual postings. Combined with no
+confirmation step anywhere in the module, a stray keypress in a reconcile buffer
+mutates the ledger file.
+
+This is upstream behavior, not a bug in this module. It is named here because the
+follow-on guardrail work asked about "reconcile safety" and this is what reconcile
+safety currently amounts to.
+
+* What is not a problem
+
+Worth recording so the guardrail work doesn't chase it.
+
+- *company-ledger's global backend is correctly scoped.* =ledger-config.el:68= does
+ =(add-to-list 'company-backends 'company-ledger)= globally, which looks like it
+ would offer ledger completions everywhere. It doesn't: the backend's own =prefix=
+ command (=company-ledger.el:110-111=) returns nil unless the buffer is
+ =beancount-mode= or derives from =ledger-mode=.
+- *The reports pass =--strict=.* All five entries in =ledger-reports=
+ (=ledger-config.el:48-52=) use =--strict=, so a report over a file with an
+ undeclared account errors rather than silently inventing one. This is a real
+ guardrail and it is on.
+- *The missing-binary check is at the right place.* =cj/executable-find-or-warn=
+ runs at =:config= (=ledger-config.el:54=), so a missing =ledger= CLI warns at load
+ rather than failing cryptically inside a report.
+- *The clean-on-save hook is installed buffer-locally* (=ledger-config.el:40=), not
+ globally. Pinned by a test.
+
+* Gaps, as a list
+
+1. +No linting reaches ledger buffers (F1).+ Fixed 2026-07-10.
+2. No confirmation before the date-sort on save (F2). Accepted: the sort is
+ chronological and stable.
+3. No rollback when that rewrite fails partway (F3).
+4. No confirmation before reconcile mutates the file (F4).
+5. No validation that a save leaves the file balanced.
+6. No test coverage before today.
+
+* What this note deliberately does not do
+
+It designs no guardrails. Whether a clean that fails should roll back, and what
+shape a reconcile confirmation takes, are preference calls about Craig's own
+accounting workflow. Those belong to the ledger guardrail UX task.
+
+The one finding that wasn't a preference call was F1: turning flycheck on in ledger
+buffers restored a guardrail the module already claimed to have. That was a defect,
+not a design choice, and it is fixed.
+
+F2 was resolved by reading the sort key rather than by changing code. What remains
+for the guardrail task is F3 (no rollback when a clean fails partway) and F4 (no
+confirmation before reconcile), plus the open question of whether a save should
+verify the file still balances.
diff --git a/docs/design/2026-07-10-org-module-ownership.org b/docs/design/2026-07-10-org-module-ownership.org
new file mode 100644
index 00000000..5717b66f
--- /dev/null
+++ b/docs/design/2026-07-10-org-module-ownership.org
@@ -0,0 +1,138 @@
+#+TITLE: Org module ownership and load boundaries
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-07-10
+
+* Why this note exists
+
+The Org workflow is spread across sixteen modules with overlapping
+responsibilities. The code works. What's missing is a map: which module owns which
+behavior, what each one adds to a shared variable, and in what order they load.
+
+Future load-order work needs that map. Four modules add capture templates, four
+touch =org-agenda-files=, and five register org-protocol handlers. None of that is
+wrong, but none of it is written down either, so the only way to answer "who sets
+this?" is to grep.
+
+This is a description of what is, not a proposal. Where the map turned up something
+that looks like a defect, it's flagged at the bottom rather than fixed here.
+
+* The modules
+
+Every Org module declares =Layer=, =Category=, and =Load shape= in its commentary
+header. The layer numbers below come from those headers, not from this note.
+
+| Module | Layer | Owns |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-faces-config.el= | 2 | Agenda keyword and priority faces, plus their -dim |
+| | | variants for unfocused windows |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-config.el= | 3 | Base org-mode settings, the org keymap, tag alignment |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-agenda-config.el= | 3 | Agenda views, task tracking, notifications; loads |
+| | | =org-agenda-config-debug= on demand |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-capture-config.el= | 3 | The base capture templates and their dispatch keys |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-refile-config.el= | 3 | Refile targets and the cached target list |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-roam-config.el= | 3 | Roam database, capture, and its agenda-file additions |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-contacts-config.el= | 3 | Contacts storage and its capture template |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-babel-config.el= | 3 | Source-block languages and tempo templates |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-export-config.el= | 3 | Export backends and their defaults |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-spec-links.el= | 3 | Resolves =[[id:]]= links into project spec docs |
+|------------------------------+-------+---------------------------------------------------------|
+| =hugo-config.el= | 3 | ox-hugo blog publishing (=C-; h=) |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-drill-config.el= | 4 | Spaced repetition, its own refile targets |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-noter-config.el= | 4 | PDF and EPUB annotation |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-reveal-config.el= | 4 | reveal.js presentations (=C-; p=) |
+|------------------------------+-------+---------------------------------------------------------|
+| =org-webclipper.el= | 4 | org-protocol web clipping into roam |
+|------------------------------+-------+---------------------------------------------------------|
+| =quick-video-capture.el= | 4 | Video download via an org-protocol bookmark |
+|------------------------------+-------+---------------------------------------------------------|
+
+* Shared variables and who writes to them
+
+This is the part grep answers slowly and a table answers fast. Each of these
+variables is appended to by more than one module.
+
+** =org-capture-templates= — four writers
+
+=org-capture-config.el= establishes the base set. =org-contacts-config.el=,
+=org-webclipper.el=, and =quick-video-capture.el= each append their own. Dispatch
+keys must not collide across all four, which is why
+=tests/test-org-capture-templates-integrity.el= exists: it loads the cleanly
+loadable capture modules, applies their additions, and asserts no two templates
+share a key.
+
+That test is the enforcement mechanism for a boundary no single module can see.
+
+** =org-agenda-files= — four writers
+
+=org-agenda-config.el= sets the base list. =org-roam-config.el= adds roam files.
+=config-utilities.el= manipulates it, and =org-agenda-config-debug.el= reads it for
+diagnostics.
+
+** org-protocol handlers — five registrars
+
+=org-config.el=, =org-capture-config.el=, =org-drill-config.el=,
+=org-webclipper.el=, and =quick-video-capture.el= all touch org-protocol. The
+handlers are keyed by protocol name, so collisions are possible in principle and
+nothing checks for them today.
+
+** =org-refile-targets= — two writers
+
+=org-refile-config.el= owns the general targets. =org-drill-config.el= adds its
+own, because drill files are refile destinations but not agenda files.
+
+* Load order
+
+=init.el= loads the Org stack at lines 130 through 145, in this order: base config,
+faces, agenda, babel, capture, contacts, drill, export, hugo, reveal, refile, roam,
+spec-links, webclipper, noter.
+
+The order is deliberate in two places. =org-config= precedes everything because it
+establishes the keymap the rest bind into. =org-faces-config= precedes
+=org-agenda-config= because the agenda renders with those faces.
+
+One module sits outside that block. =quick-video-capture.el= loads at line 106,
+twenty-four lines before =org-config=, because it is grouped with the media modules
+rather than the Org modules. It nonetheless appends to =org-capture-templates=. It
+works because the append is a plain =add-to-list= against a variable org-mode has
+not yet claimed, but the grouping means a reader looking for "who adds capture
+templates" in the Org block will not find it.
+
+* Load shape
+
+Every Org module declares =Load shape: eager=. Nothing in the Org stack defers.
+
+This is the single largest fact about the Org workflow's startup cost, and it is
+worth stating plainly rather than leaving implicit across sixteen headers. Layer-4
+modules (drill, noter, reveal, webclipper, video capture) are optional features by
+their own classification, and all five load eagerly on every launch.
+
+Whether that matters is a measurement question, not an argument, and this note does
+not make it. It records the fact so the question can be asked.
+
+* Observations for follow-up
+
+These turned up while mapping. None is fixed here.
+
+1. =modules/org-config.el.faces.bak= is tracked in git (25KB). It appears to be a
+ leftover from the 2026-06-14 face-stripping work. A =.bak= file in =modules/=
+ is not loaded, but it is on the load-path's directory and will confuse the next
+ reader.
+2. =org-agenda-config-debug.el= carries no =Layer= / =Category= / =Load shape=
+ header, unlike every other module. It loads conditionally from
+ =org-agenda-config.el:42= when =cj/debug-modules= names it, so its absence from
+ =init.el= is by design, not an orphan.
+3. Nothing checks org-protocol handler collisions the way the capture-template test
+ checks key collisions. Five modules register handlers.
+4. Every Layer-4 Org module loads eagerly despite being classified optional.
diff --git a/docs/design/signal-client-review.org b/docs/design/signal-client-review.org
index 7e8a73e9..e1ac462e 100644
--- a/docs/design/signal-client-review.org
+++ b/docs/design/signal-client-review.org
@@ -5,7 +5,7 @@
* Scope reviewed
- =.ai/workflows/spec-review.org=.
-- =docs/specs/signal-client-spec-doing.org=, including the base design, open-question dispositions, initiate-message workflow, architecture additions, accepted caveats, test plan, scope summary, and readiness rubric.
+- =docs/specs/signal-client-spec.org=, including the base design, open-question dispositions, initiate-message workflow, architecture additions, accepted caveats, test plan, scope summary, and readiness rubric.
- =modules/signal-config.el=, including =cj/signal--parse-contacts=, notify-suppression helpers, private config loading, and current =use-package signel= wiring.
- =~/code/signel/signel.el=, including =signel-start=, =signel--send-rpc=, =signel--dispatch=, =signel--handle-error=, =signel--handle-receive=, =signel--insert-msg=, =signel--insert-system-msg=, =signel--send-input=, =signel-chat=, and dashboard commands.
- =tests/test-signal-config.el=, covering contact parsing and notify-suppression helpers.
diff --git a/docs/design/subr-mock-migration.org b/docs/design/subr-mock-migration.org
new file mode 100644
index 00000000..874d03ef
--- /dev/null
+++ b/docs/design/subr-mock-migration.org
@@ -0,0 +1,158 @@
+#+TITLE: Spec: Migrate Tests Off Mocking C Primitives
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-30
+#+STATUS: Draft — for discussion
+
+* Status
+
+Draft. Pulled out of =todo.org= (=** TODO [#C] Migrate tests off mocking
+primitives (native-comp robustness) :test:refactor:solo:=) so the scope and
+approach can be settled before any code moves. Execution is deferred; this
+document is the discussion vehicle.
+
+Companion reference: [[file:../native-comp-subr-mocking.org][native-comp-subr-mocking.org]] holds the full mechanism, the
+upstream research, and the 2026-06-21 decision. This spec does not restate the
+mechanism; it plans the remaining work that decision deferred.
+
+* Background — how we hit this
+
+We re-enabled native compilation config-wide (early-init.el, commit 3fd28987,
+2026-06-20). Tests that had been green for months immediately started failing
+with no change to their source — the first 8 were window-primitive mocks in
+=test-dirvish-config-wrappers.el= and =test-calibredb-epub-config.el=
+(=window-body-width=, =window-margins=, =current-window-configuration=,
+=get-buffer-window=), throwing =wrong-number-of-arguments= for a zero-arg mock
+lambda called with one argument.
+
+** What we struggled with (the consequences)
+
+- *Intermittent, non-deterministic failure.* The same test passed, then
+ crashed, then passed again within a session. Native-comp generates a
+ per-primitive trampoline =.eln= lazily and caches it on disk; whether a mock
+ "works" depends on whether that trampoline has been built yet. The
+ non-determinism was the tell, and it made the failures hard to trust or
+ reproduce.
+- *Three distinct failure modes from one cause* (full detail in the companion
+ doc): (1) trampoline generation failure under =--batch=; (2) silent bypass —
+ natively-compiled callers ignore the mock and run the real primitive, so a
+ test passes for the wrong reason; (3) arity mismatch — the trampoline calls
+ the mock with the primitive's *maximum* arity, so a fixed-arity mock narrower
+ than the primitive throws. Mode 3 is the one that bit us; modes 1 and 2 sit
+ latent.
+- *The tempting quick fix is the dangerous one.* Disabling subr trampolines
+ (=native-comp-enable-subr-trampolines nil=) is the most-cited workaround, but
+ in our native-comp-heavy setup it produces mode 2 — tests that pass while
+ asserting against the real primitive. A quiet false pass is worse than a loud
+ crash.
+- *Scale of the latent surface.* The suite mocks subrs in hundreds of places.
+ The variadic sweep touched 188 arity-narrow mocks.
+
+** What is already done (the stopgap, 2026-06-21)
+
+Not currently broken — two things shipped (commits 571da499, b62c3c88):
+
+1. *Variadic sweep.* Every arity-narrow subr mock got =&rest _= appended, which
+ tolerates the trampoline's full-arity call. Deterministic, keeps trampolines
+ on, so no silent bypass. Fixes mode 3.
+2. *Meta-test gate.* =tests/test-meta-subr-mock-arity.el= statically scans every
+ test file for =symbol-function= / =fset= / =setf= subr redefinitions and
+ fails =make test= if any mock can't accept the primitive's maximum arity. A
+ new arity-narrow mock can't merge silently.
+
+The stopgap fixes the mode we actually suffered. It leaves modes 1 and 2 latent.
+The durable fix the ecosystem (and our own =elisp-testing.md=) points to is to
+*not redefine primitives at all*. That is the work this spec scopes.
+
+* The real scope — most mocks should NOT move
+
+The raw inventory is large, but the headline number is misleading. =testing.md=
+says to mock external boundaries; converting those to "drive real state" would
+mean running real shells and touching the real filesystem in unit tests — the
+opposite of what we want. So the actual migration target is narrow.
+
+Current subr-mock sites across 261 test files (=cl-letf= / =fset= / =advice-add=
+on the named primitive):
+
+| Primitive | Sites | Classification | Disposition |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| shell-command-to-string | 62 | external boundary | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| executable-find | 60 | external boundary | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| shell-command | 29 | external boundary | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| call-process | 17 | external boundary | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| current-time | 11 | time boundary | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| save-buffer | 10 | file I/O boundary | keep, or real temp-file fixture |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| write-region | 4 | file I/O boundary | keep, or real temp-file fixture |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| message | 69 | output-silencing | keep mocked (variadic) |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| completing-read | 25 | UI prompt | MIGRATE — extract pure internal |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| read-string | 16 | UI prompt | MIGRATE — extract pure internal |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| yes-or-no-p | 14 | UI prompt | MIGRATE — extract pure internal |
+|-------------------------+-------+-------------------------+-----------------------------------|
+| read-from-minibuffer | 6 | UI prompt | MIGRATE — extract pure internal |
+|-------------------------+-------+-------------------------+-----------------------------------|
+
+The genuine migration target is the UI-prompt bucket: ~61 sites. Per
+=elisp-testing.md='s Interactive-vs-Internal rule, the fix is to extract a pure
+internal that takes the value as an argument and test that directly, leaving the
+interactive wrapper a thin un-tested (or smoke-tested) shell. That removes the
+prompt mock entirely — immune to all three failure modes — and improves the
+production code's testability as a side effect.
+
+Boundary mocks (shell, file I/O, time, =executable-find=, =call-process=) stay
+mocked: that is correct unit-test practice, and the variadic form already
+handles native-comp. =message= is output-silencing, not logic — keep it.
+
+* Proposed approach
+
+Not a single sweep. The migration touches production code (each extraction is a
+small design change), so it is incremental and reviewable, not mechanical.
+
+1. *Scoped exemplar pass first.* Pick one module with a few prompt-mocks, do the
+ extract-internal conversion there, set the pattern, and measure the per-case
+ effort. This calibrates the rest.
+2. *Batch by module afterward.* Convert remaining UI-prompt sites a module at a
+ time, each its own commit, with the suite green between.
+3. *Leave boundaries alone.* No conversion of shell / file / time / process
+ mocks. The meta-test keeps them arity-safe.
+
+* Open decisions (resolve in discussion)
+
+** TODO Confirm the scope: UI-prompt mocks only, boundaries stay
+Is the migration scoped to the ~61 UI-prompt sites (completing-read, read-string,
+yes-or-no-p, read-from-minibuffer), with all boundary mocks explicitly out of
+scope? Or is there an appetite to also convert the file-I/O mocks
+(=save-buffer=, =write-region=) to real temp-file fixtures where it reads
+cleaner?
+
+** TODO Reframe the todo.org task title to match the real scope
+The current title — "Migrate tests off mocking primitives" — reads as all 300+
+sites. If we agree on UI-prompt-only, retitle to something like "Extract pure
+internals for UI-prompt-mocked tests" so a future session does not re-scope it
+as a wholesale sweep.
+
+** TODO Pick the exemplar module for the first pass
+Which module gets the calibrating conversion? A small one with 2-4 prompt-mocks
+is ideal. Candidate selection needs a per-module breakdown of the ~61 sites
+(not yet collected).
+
+** TODO Decide priority and timing
+Currently =[#C]=, =:solo:=. The suite is not broken (stopgap holds), so this is
+test-quality debt, not urgent. Confirm it stays low and gets done in batches
+between other work, rather than as a dedicated push.
+
+* Non-goals
+
+- Re-deriving or re-documenting the native-comp trampoline mechanism (see the
+ companion doc).
+- Converting boundary mocks (shell, file I/O, time, process, executable-find).
+- Removing the variadic-mock convention or the meta-test gate — both stay; they
+ are the standing protection for every mock that legitimately remains.
diff --git a/docs/design/utility-inventory.org b/docs/design/utility-inventory.org
index 8438a592..9b811429 100644
--- a/docs/design/utility-inventory.org
+++ b/docs/design/utility-inventory.org
@@ -4,7 +4,7 @@
* Status
-Living inventory. Phase 1 of [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]]. Records the current state of helpers identified in the spec's Candidate Extraction Table plus any new candidates discovered during module walkthroughs. Decisions become concrete tasks in =todo.org= for Phase 2+.
+Living inventory. Phase 1 of [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec.org]]. Records the current state of helpers identified in the spec's Candidate Extraction Table plus any new candidates discovered during module walkthroughs. Decisions become concrete tasks in =todo.org= for Phase 2+.
* Scope
@@ -82,7 +82,7 @@ Caller counts in the inventory below reflect grep results from 2026-05-10. The c
| Symbol | File | Vis | Deps | Side effects | Proposed home / name | Callers (modules) | Tests | Pri | Decision | Rationale |
|--------+------+-----+------+--------------+----------------------+-------------------+-------+-----+----------+-----------|
-| =cj/modeline-vc-cache-*= helpers (key/get/put/clear/valid-p) | =modeline-config.el:108-140= | private | buffer-local vars | mutates buffer-local state | =cj-cache.el= / =cj/cache-valid-p=, =cj/cache-get=, =cj/cache-put=, =cj/cache-clear= | 1 (within file) | =test-modeline-config-vc-cache.el= | Medium | Defer | Good pattern, but variable-local cache shape differs from the agenda/refile caches. Needs design before extraction. Spec calls out a Phase 5 design addendum at =docs/specs/cache-helper-design-spec-implemented.org=. |
+| =cj/modeline-vc-cache-*= helpers (key/get/put/clear/valid-p) | =modeline-config.el:108-140= | private | buffer-local vars | mutates buffer-local state | =cj-cache.el= / =cj/cache-valid-p=, =cj/cache-get=, =cj/cache-put=, =cj/cache-clear= | 1 (within file) | =test-modeline-config-vc-cache.el= | Medium | Defer | Good pattern, but variable-local cache shape differs from the agenda/refile caches. Needs design before extraction. Spec calls out a Phase 5 design addendum at =docs/specs/cache-helper-design-spec.org=. |
| agenda/refile cache vars and build flags | =org-agenda-config.el=, =org-refile-config.el= | n/a | timers, file scans | scans filesystem, sets vars | =cj-cache.el= / =cj/cache-value-or-rebuild= | 2 | none | Medium | Defer | TTL/build/invalidate lifecycle; higher risk than the modeline cache. Same Phase 5 work. |
** Logging / Warnings
@@ -144,7 +144,7 @@ These become =todo.org= entries (or update existing ones) as Phase 2 starts.
** Deferred (track in =todo.org= but no commit yet)
-- Cache abstraction (modeline + agenda/refile) -- needs Phase 5 design addendum at =docs/specs/cache-helper-design-spec-implemented.org=.
+- Cache abstraction (modeline + agenda/refile) -- needs Phase 5 design addendum at =docs/specs/cache-helper-design-spec.org=.
- =cj/--open-with-is-launcher-p= -- move when external-open ownership is finalized.
- =cj/log-silently= rename -- low value; do during incidental =system-lib= work.
- HTML/text helpers (=strip-html=, =clean-text=) -- defer until a second consumer.