diff options
Diffstat (limited to 'docs/design/utility-consolidation.org')
| -rw-r--r-- | docs/design/utility-consolidation.org | 1216 |
1 files changed, 1216 insertions, 0 deletions
diff --git a/docs/design/utility-consolidation.org b/docs/design/utility-consolidation.org new file mode 100644 index 00000000..b8428380 --- /dev/null +++ b/docs/design/utility-consolidation.org @@ -0,0 +1,1216 @@ +#+TITLE: Design: Consolidate Shared Utility Helpers +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-04 + +* Status + +Draft. Specification only. No helper extraction is part of this document. + +This is the sibling project to [[file:init-load-graph.org][Untangle the init.el Load Graph]]. The load-graph +project decides when modules load and what dependencies they declare. This +project decides which module should own reusable helper behavior. + +* Framing Questions + +Before extracting helpers, ask these questions for each candidate: + +1. Is this behavior duplicated, or merely similar? +2. Does the proposed helper have at least two real consumers? +3. Can the helper live in a foundation library without pulling heavy packages + into startup? +4. Is the helper pure or mostly pure, or does it create files, processes, + timers, buffers, warnings, messages, or network traffic? +5. Does it need to be available to production code, tests only, or both? +6. Will extraction make the caller easier to read, or will it hide important + domain decisions behind a vague utility name? +7. Can the tests move with the helper while preserving consumer behavior tests? +8. Is an existing Emacs primitive already good enough? +9. Is this a library function, a command helper, or a module-specific private + detail? +10. What compatibility story is needed for existing public =cj/= commands? + +The default answer should be "do not extract yet" unless the helper has clear +reuse pressure and low dependency cost. The exception is a helper that currently +lives in a clearly wrong dependency layer and blocks the load-graph work; those +speculative extractions are allowed only when this spec names the expected +future consumers and the migration keeps the old caller behavior covered. + +* Candidate Decision Criteria + +Use intent first and implementation shape second. A helper is a real extraction +candidate when multiple callers are enforcing the same policy, even if the +current functions were written differently. + +Do not reject a candidate just because the implementations differ. Differences +may be accidental drift, local naming, or missing parameters. Also do not accept +a candidate just because the implementations look similar. Similar code can +still represent different workflow policies. + +Treat a candidate as real when most of these are true: + +- Same job: one domain-neutral sentence describes what both callers are trying + to do. +- Same failure semantics: callers agree on whether failure should return nil, + warn, signal =user-error=, skip silently, or fall back. +- Same side-effect policy: callers agree on whether the helper may message, + warn, write files, create buffers, start processes, or mutate state. +- Same dependency layer: the helper can live somewhere both callers can + reasonably require without pulling heavy package/domain dependencies into + foundation startup. +- Differences are parameters, not hidden modes: warning type, feature name, + current directory, TTL, or trim behavior are reasonable parameters; broad + flags that make one helper behave like several unrelated helpers are a smell. +- Tests can describe the policy without loading unrelated domain modules. +- Call sites get clearer because the shared policy has a good name and the + caller still owns workflow-specific consequences. + +When callers share only part of the intention, extract the shared policy core +and leave workflow decisions local. For example, executable lookup can be shared +while mail, programming, and media modules still decide whether a missing tool +means "disable sync," "skip a hook," or "show an interactive warning." + +* Problem + +Several modules define helper functions where they were first needed. Some of +those helpers are truly private. Others are general utilities that now have +multiple consumers or obvious near-duplicates. This creates architectural drag: + +- A feature module becomes the accidental owner of generic behavior. +- Other modules either duplicate the behavior or depend on a feature module for + a helper they should not conceptually require. +- Tests are harder to place because helper logic is mixed with package config, + keybinding setup, timers, external processes, and user commands. +- The =init.el= load graph stays harder to untangle because helper ownership is + not explicit. + +The goal is not to build a large personal standard library. The goal is to +extract a small set of proven helpers into predictable, dependency-light +libraries with focused tests. + +A prior review estimated roughly 221 private helpers across 31 modules. That +count is useful motivation for an inventory, but it is not the extraction +target. This project should pull only the helpers whose ownership and reuse +case are clear. + +* Goals + +- Identify concrete helper functions that should be moved, renamed, wrapped, or + deliberately left alone. +- Keep foundation helpers dependency-light and safe to load early. +- Give each helper family a clear home and naming convention. +- Preserve existing behavior at call sites. +- Move unit tests with extracted helper behavior. +- Keep migrations small: one helper family per commit. +- Improve direct module loading by replacing hidden cross-module assumptions + with explicit =require= statements. + +* Non-Goals + +- Renaming every =cj/= function. +- Turning command modules into libraries when their behavior is user-facing. +- Extracting helpers with only one consumer unless they are already in the wrong + dependency layer. +- Replacing useful built-in APIs such as =file-in-directory-p= with wrappers + that add no policy. +- Moving heavy package-specific behavior into =system-lib=. +- Combining this work with lazy-loading changes in the same commit. + +* Existing Library Shape + +A file with user-facing interactive commands is a feature/command module, not a +shared library. Mixed files can keep private helper functions, but those helpers +should move to =system-lib.el= or a topic library only when reuse pressure and +the candidate criteria justify extraction. + +** =system-lib.el= + +Current role: low-level system utility library. It is already expected to be a +foundation module. + +Current functions: + +- =cj/executable-exists-p= +- =cj/log-silently= + +Recommended role: foundation helpers that are dependency-light, batch-safe, and +reasonable to load early. + +Good fits: + +- executable lookup, +- shell argument formatting, +- process execution wrappers built on =process-file=, +- warning/message convenience wrappers, +- simple file/path predicates, +- simple file string read/write helpers. + +Bad fits: + +- helpers that require Org, mu4e, projectile, dirvish, vc-git, url, gptel, or + other package/domain dependencies, +- helpers that start asynchronous processes/timers at load time, +- helpers whose semantics are really workflow-specific. + +Dependency budget: + +- Allowed without additional design: built-ins already available at startup, + =subr-x=, =cl-lib=, and =seq=. +- Allowed only with an explicit note in the helper's section: + =host-environment=, because it participates in early foundation/load-order + decisions. +- Not allowed in =system-lib.el=: Org, VC internals such as =vc-git=, Dired + implementation packages beyond declarations, url/network libraries, mu4e, + projectile, dirvish, gptel, media packages, or any package that would make + optional workflows part of foundation startup. + +Any helper needing dependencies outside this budget belongs in a topic library +or the domain module that already owns that dependency. + +** =system-utils.el= + +Current role: user-facing system commands and Emacs enhancements. It mixes +commands, package config, keybindings, external open behavior, savehist, +scratch-buffer setup, dictionary, and proced. + +Recommended role: command/config module, not a low-level library. + +Potential extraction from here: + +- =cj/--file-from-context= should move to =system-lib= as a generic path helper. +- =cj/--open-with-is-launcher-p= should move if external-open behavior is shared. +- =cj/identify-external-open-command= should move to =external-open.el= as + workflow-owned command resolution. + +After extraction, =system-utils.el= should require the library and keep the +interactive commands. + +** =config-utilities.el= + +Current role: interactive maintenance/debug commands for this Emacs config. + +Recommended role: keep as command/config module. Do not turn it into a generic +library. + +Potential extraction: + +- =with-timer= should become =cj/with-timer= only if other modules need the + macro. +- =cj/--delete-compiled-files-in-dir= may become a generic recursive file + deletion helper only if another production caller needs the same behavior and + the destructive policy is explicit. +- build-info formatting helpers should stay here; they are command-specific. + +** =testutil-general.el= + +Current role: test-only filesystem helpers. + +Recommended role: keep test harness helpers here unless a production module +needs the same safety policy. Do not make production code depend on +=testutil-general=. + +Potential production extraction: + +- =cj/test--assert-inside-base= -> =cj/path-assert-in-directory= +- =cj/test--safe-base-dir-p= -> =cj/safe-recursive-delete-root-p= + +These should move only when a production destructive workflow needs them. + +* Library File Header Standard + +Shared library files should document their own scope in the commentary header. +The design spec records the rationale; the file header is the contributor-facing +contract that should be visible during ordinary edits. + +Required header content: + +1. =;; Role:= foundation utility library or topic library role. +2. =;; Layer:= matching the load-graph architecture. +3. =;; Dependency budget:= allowed, note-required, and forbidden dependencies. +4. =;; What belongs here:= concrete helper families. +5. =;; What does not belong here:= workflow/domain behavior and heavy + dependencies. +6. =;; Adding a helper:= two-consumer rule plus wrong-layer speculative + exception. +7. =;; Naming:= public helper naming policy. +8. =;; Tests:= expected test-file convention. +9. =;; Renaming:= obsolete alias policy. +10. =;; See also:= design docs for rationale. + +Worked =system-lib.el= header: + +#+begin_src emacs-lisp +;;; system-lib.el --- Foundation utility helpers -*- lexical-binding: t; -*- +;; +;;; Commentary: +;; +;; Role: Foundation utility library (Layer 1). +;; +;; This file owns reusable, dependency-light helpers used across feature +;; modules. It loads early in startup and must stay batch-safe. +;; +;; Dependency budget: +;; Allowed without note: built-ins, subr-x, cl-lib, seq. +;; Allowed with note: host-environment (foundation peer). +;; Not allowed: Org, vc-git internals, Dired implementation, +;; url/network libraries, mu4e, projectile, +;; dirvish, gptel, media packages, or any package +;; that would attach optional workflows to +;; foundation startup. +;; +;; What belongs here: +;; - executable lookup (silent predicate or warn-and-return) +;; - shell argument formatting (readable-when-safe quoting) +;; - process execution wrappers built on `process-file' +;; - simple file/path predicates and context resolution +;; - logging convenience wrappers +;; +;; What does not belong here: +;; - workflow-specific behavior (calendar parsing, Org-roam slug generation, +;; gptel adapters) +;; - timers, network requests, or buffer mutations at load time +;; - helpers that pull a heavy package into foundation startup +;; +;; Adding a helper: +;; Default to "do not extract yet." Extract when at least two callers share +;; the same job, failure semantics, side-effect policy, and dependency layer. +;; Speculative extractions are allowed only when a feature module is the +;; wrong long-term owner; record expected future consumers in +;; docs/design/utility-inventory.org. +;; +;; Naming: +;; Public helpers use cj/<noun>-<verb> or cj/<domain>-<verb>. Names should +;; describe policy, not just shape. Do not retain source-module prefixes +;; after extraction. +;; +;; Tests: +;; tests/test-system-lib-<helper-name>.el, one file per helper. Stub +;; side-effecting primitives at their boundaries via cl-letf. +;; +;; Renaming: +;; Public helpers in user muscle memory get a one-cycle obsolete alias. +;; Private helpers rename without alias when all call sites change in the +;; same commit. +;; +;; See also: docs/design/utility-consolidation.org for design rationale. +;; +;;; Code: +#+end_src + +Topic libraries such as =cj-process.el=, =cj-org-text.el=, or =cj-cache.el= +should follow the same shape with a narrower role and dependency budget. + +* Proposed Library Layout + +Start with single-file growth in =system-lib.el=. Split later only when the file +becomes too broad or a helper family needs a dependency that should not be +foundation-eager. + +Recommended phases: + +1. Grow =system-lib.el= for the first dependency-light helpers. +2. Keep Org-specific helpers out of =system-lib= unless they can be written with + only strings and =subr-x=. +3. Introduce topic libraries only when there is a clear reason: + - =cj-process.el= for process runners if the process API grows beyond a + couple functions. + - =cj-org-text.el= for Org-safe text helpers if they start requiring Org + APIs. + - =cj-cache.el= for cache helpers because that abstraction is stateful and + distinct from simple system helpers. +4. Preserve =system-lib.el= as the easy entry point for the low-level set. + +Load shape: + +- Each topic library declares its load-graph layer in its file header. +- =cj-process.el= and =cj-org-text.el= are Layer 1 only if their first consumer + is foundation-eager; otherwise they are Layer 2 and loaded by explicit + =require= from eager consumers. +- =cj-cache.el= follows the first real cache consumer's layer, likely Layer 2 if + modeline/agenda/refile remain eager or near-eager. +- Coordinate every new topic library with + [[file:init-load-graph.org][init-load-graph.org]] before migrating its first consumer. + +* Naming Rules + +- Library files use =cj-<topic>.el=. The legacy =system-lib.el= name stays for + compatibility and serves as the foundation entry point. +- Public reusable helpers use =cj/<noun>-<verb>= or =cj/<domain>-<verb>=. +- Private module helpers keep =cj/<module>--<helper>= or + =<module>--<helper>=. +- Do not keep source-module names after extraction. For example, + =cj/mail--executable-or-warn= should not become + =cj/system-mail-executable-or-warn=. +- Prefer names that describe policy: + - =cj/executable-find-or-warn= is better than =cj/check-program=. + - =cj/shell-quote-argument-readable= is better than =cj/shell-quote= because + it documents the readable-when-safe policy. +- Preserve public interactive command names unless there is a separate + user-facing rename task. +- Add obsolete aliases only for functions used outside their defining module or + in user muscle memory. Private helpers can be renamed without aliases when + all call sites change in the same commit. + +* Candidate Extraction Table + +This table is intentionally concrete. "Action" describes the recommended end +state, not necessarily the first commit. + +| Current symbol | Current file | Proposed symbol | Proposed home | Action | Priority | Notes | +|----------------+--------------+-----------------+---------------+--------+----------+-------| +| =cj/mail--executable-or-warn= | =mail-config.el= | =cj/executable-find-or-warn= | =system-lib.el= | Extract | High | Generalizes missing executable warning; callers include mail, language tools, media/dirvish commands. | +| =cj/executable-exists-p= | =system-lib.el= | =cj/executable-available-p= | =system-lib.el= | Rename, alias preserved | Medium | New predicate returns boolean. Keep one-cycle obsolete alias/wrapper because the current name is misleading and returns a path. | +| direct =(executable-find ...)= with silent nil | =prog-c.el=, =prog-go.el=, =prog-python.el=, =prog-shell.el=, =dirvish-config.el=, =browser-config.el=, =mail-config.el= | =cj/executable-find-or-warn= or =cj/executable-available-p= | =system-lib.el= | Migrate selectively | High | Use warnings for user-invoked missing features; keep silent predicates for package =:if= checks when silence is intentional. | +| =cj/--f6-shell-safe-argument-regexp= | =dev-fkeys.el= | =cj/shell-safe-argument-regexp= | =system-lib.el= | Extract | High | Keep as implementation detail for readable shell quoting. | +| =cj/--f6-shell-quote-argument= | =dev-fkeys.el= | =cj/shell-quote-argument-readable= | =system-lib.el= | Extract | High | Useful for generated compile/test strings where safe paths should remain readable. | +| direct =shell-quote-argument= in command strings | =prog-c.el=, =prog-python.el=, =prog-shell.el=, =mail-config.el=, =dirvish-config.el=, =vc-config.el=, =elfeed-config.el=, =system-utils.el= | case-by-case | =system-lib.el= | Audit | Medium | Do not blindly replace; direct quoting is correct when readability is irrelevant or command strings are security-sensitive. | +| =cj/--coverage-git-string= | =coverage-core.el= | =cj/process-output-or-error= | =system-lib.el= or =cj-process.el= | Extract generic core | High | Generic process-file wrapper: program + argv -> stdout or user-error with status/output. | +| =cj/--coverage-git-string= | =coverage-core.el= | =cj/git-output-or-error= | =system-lib.el= or =cj-process.el= | Add wrapper | High | Thin wrapper around generic runner with program ="git"=. | +| =cj/--coverage-git-merge-base= | =coverage-core.el= | keep =cj/--coverage-git-merge-base= | =coverage-core.el= | Keep | Low | Coverage-specific semantics; may call =cj/git-output-or-error=. | +| =cj/--coverage-git-diff= | =coverage-core.el= | keep =cj/--coverage-git-diff= | =coverage-core.el= | Keep | Low | Coverage-specific =--unified=0= policy. | +| =cj/--file-from-context= | =system-utils.el= | =cj/file-from-context= | =system-lib.el= | Extract | High | Useful for Dired/current-buffer command helpers. Requires only dired declarations and built-ins. | +| =cj/--open-with-is-launcher-p= | =system-utils.el= | =cj/external-open-launcher-p= | =external-open.el= | Extract after consumers align | Medium | External-open policy, not core path logic. | +| =cj/identify-external-open-command= | =system-utils.el= | =cj/external-open-command= | =external-open.el= | Move/rename | Medium | External-open owns command-string resolution; host-environment remains predicate-only. | +| duplicated OS-open command selection | =system-utils.el=, =dirvish-config.el=, =external-open.el= | =cj/external-open-command= | =external-open.el= | Consolidate | Medium | One source of truth for =xdg-open=, =open=, =start=. | +| =cj/test--file-in-directory-p= | =test-runner.el= | =file-in-directory-p= (built-in) | built-in | Replace caller with built-in | Medium | Do not create a wrapper unless a real normalization policy emerges. | +| =cj/test--assert-inside-base= | =testutil-general.el= | =cj/path-assert-in-directory= | =system-lib.el= | Extract only with production caller | Medium | Useful for destructive commands, but currently test-only. | +| =cj/test--safe-base-dir-p= | =testutil-general.el= | =cj/safe-recursive-delete-root-p= | =system-lib.el= | Extract only with production caller | Medium | Policy-heavy. Should be explicit and well-tested before production use. | +| =calendar-sync--sanitize-org-body= | =calendar-sync.el= | =cj/org-sanitize-body-text= | =cj-org-text.el= or =system-lib.el= | Extract | High | Already tested; likely useful for webclipper, AI conversations, mail capture. | +| =calendar-sync--sanitize-org-property-value= | =calendar-sync.el= | =cj/org-sanitize-property-value= | =cj-org-text.el= or =system-lib.el= | Extract | High | String-only behavior; no Org dependency required. | +| =calendar-sync--sanitize-org-heading= | =calendar-sync.el= | =cj/org-sanitize-heading= | =cj-org-text.el= or =system-lib.el= | Extract | High | Protects outline structure from external text. | +| =calendar-sync--strip-html= | =calendar-sync.el= | =cj/text-strip-html= | =system-lib.el= or =cj-text.el= | Consider | Medium | Useful beyond calendar, but HTML stripping via regex is intentionally simple and should be documented as such. | +| =calendar-sync--clean-text= | =calendar-sync.el= | =cj/text-clean-external= | =system-lib.el= or =cj-text.el= | Consider | Medium | Combines ICS unescape + HTML strip today, so it may be too calendar-specific unless split. | +| =calendar-sync--unescape-ics-text= | =calendar-sync.el= | keep =calendar-sync--unescape-ics-text= | =calendar-sync.el= | Keep | Low | ICS-specific; not a general utility. | +| =cj/modeline-vc-cache-*= helpers | =modeline-config.el= | =cj/cache-valid-p=, =cj/cache-get=, =cj/cache-put=, =cj/cache-clear= | =cj-cache.el= | Extract later | Medium | Good pattern, but variable-local cache shape differs from Org caches. Needs design before extraction. | +| agenda/refile cache vars and build flags | =org-agenda-config.el=, =org-refile-config.el= | =cj/cache-value-or-rebuild= or =cj/build-cache= | =cj-cache.el= | Extract later | Medium | Similar TTL/building/invalidation lifecycle. Higher risk than simple helpers. | +| =cj/log-silently= | =system-lib.el= | =cj/message-log-only= | =system-lib.el= | Rename, alias preserved | Low | Clearer name for discoverability, but low value. Do after higher-priority helpers unless touched nearby. | +| direct =display-warning= boilerplate | =mail-config.el= and future callers | =cj/display-warning-once= / =cj/warn-once= | =system-lib.el= | Add after second caller | Low | Do not add until repeated formatting or once-only behavior appears. | +| =with-timer= | =config-utilities.el= | =cj/with-timer= | =system-lib.el= or stay | Defer | Low | Macro is useful, but currently debug-oriented. Extract only after another production caller appears. | +| =cj/theme-read-file-contents= | =ui-theme.el= | =cj/read-file-string= | =system-lib.el= | Consider | Low | Built-in =insert-file-contents= wrappers are small; extract only if multiple callers emerge. | +| =cj/theme-write-file-contents= | =ui-theme.el= | =cj/write-file-string= | =system-lib.el= | Consider | Low | Same as above. Keep theme-specific unless reused. | +| =cj/modeline-string-cut-middle= | =modeline-config.el= | =cj/string-truncate-middle= | =system-lib.el= or =cj-text.el= | Defer | Low | Only one current production caller. Good candidate if completion, headings, or report buffers need it. | +| =cj/--benchmark-method= | =config-utilities.el= | keep | =config-utilities.el= | Keep | Low | Debug command helper, not general architecture. | +| =cj/--delete-compiled-files-in-dir= | =config-utilities.el= | =cj/delete-files-recursively-matching= | maybe =system-lib.el= | Defer | Low | Destructive behavior needs a strong second caller and path safety contract. | + +* Recommended Helper Groups + +** Group 1: Executable Discovery + +Home: =system-lib.el=. + +Proposed API: + +#+begin_src emacs-lisp +(defun cj/executable-available-p (program) + "Return non-nil when PROGRAM resolves to an executable in PATH.") + +(defun cj/executable-find-or-warn (program feature &optional warning-type) + "Return PROGRAM's executable path, or warn that FEATURE is unavailable.") +#+end_src + +Migration: + +- Rename =cj/executable-exists-p= to =cj/executable-available-p= and keep a + one-cycle obsolete alias/wrapper for compatibility. +- Replace =cj/mail--executable-or-warn= first because it is already the exact + behavior. +- Audit language modules: + - Keep existing =use-package :if= checks on built-in =executable-find= during + this project unless there is a separate load-order reason to change them. + - Use =cj/executable-find-or-warn= for interactive commands where the user + asked for a feature and needs a clear explanation. +- Do not warn during startup for every optional language tool unless the feature + is explicitly configured to be active. + +Behavior: + +- =program= is a non-empty string naming an executable. +- =cj/executable-available-p= returns =t= or =nil=, never the executable path. +- =cj/executable-find-or-warn= returns the resolved executable path or =nil=. +- =feature= is a human-readable string used in the warning message. +- =warning-type= defaults to ='system-lib= unless the caller passes a more + specific module symbol. +- Missing executables warn with level =:warning=. +- Invalid =program= values (non-string or empty string) signal + =wrong-type-argument= via =cl-check-type=. This is a programmer-error path; + user-facing error reporting is the caller's responsibility. + +Tests: + +- program string validation, +- successful lookup returns path, +- missing program returns nil, +- warning type defaults sensibly, +- warning message includes program and feature. + +** Group 2: Shell Command String Helpers + +Home: start in =system-lib.el=. + +Proposed API: + +#+begin_src emacs-lisp +(defconst cj/shell-safe-argument-regexp "\\`[[:alnum:]_./=+@%:,^-]+\\'") + +(defun cj/shell-quote-argument-readable (argument) + "Return ARGUMENT unchanged when safe, otherwise shell-quote it.") +#+end_src + +Policy: + +- Use this helper only when building shell command strings for display, + compilation, or logging and readable safe paths matter. +- Use plain =shell-quote-argument= when maximum conservatism is preferred and + readability does not matter. +- Prefer argv/process APIs over shell strings for new process execution when + possible. + +First consumers: + +- =dev-fkeys.el= F6 command builder. +- Candidate later consumers: =prog-c.el= compile command generation, + =prog-python.el= test/debug commands, =prog-shell.el= shellcheck command, + =mail-config.el= mbsync command. + +Justification: + +- This is a speculative extraction with one current concrete consumer. It is + allowed because =dev-fkeys.el= is not the right long-term owner for shell + argument policy, the helper is dependency-free, and several command-building + modules already make the same readability/security tradeoff manually. + +Behavior: + +- =argument= must be a string. +- Arguments matching =cj/shell-safe-argument-regexp= are returned unchanged. +- Other arguments are passed to =shell-quote-argument=. +- This helper is for shell command strings only. New process execution helpers + should accept argv lists and should not use this helper internally. + +Tests: + +- safe paths unchanged, +- whitespace quoted, +- shell metacharacters quoted, +- nil/non-string behavior explicitly errors or normalizes. + +** Group 3: Process Execution + +Home: =system-lib.el= initially, split to =cj-process.el= if the API grows. + +Proposed API: + +#+begin_src emacs-lisp +(cl-defun cj/process-output-or-error + (program args &key cwd stdin error-message trim) + "Run PROGRAM with ARGS via `process-file' and return stdout.") + +(defun cj/git-output-or-error (&rest args) + "Run git with ARGS and return stdout, or signal `user-error'.") +#+end_src + +Minimum behavior: + +- Accept argv as a list, not a shell command string. +- Capture stdout/stderr together unless a caller needs them separated. +- Include program, args, exit status, and trimmed output in failure errors. +- Optionally bind =default-directory= to =cwd=. +- Return raw stdout by default; allow =:trim t= for callers that need it. + +First migration: + +- Extract the generic logic from =cj/--coverage-git-string=. +- Keep =cj/--coverage-git-merge-base= and =cj/--coverage-git-diff= in + =coverage-core.el= because their semantics are coverage-specific. + +Justification: + +- This is a speculative extraction with one current concrete consumer. It is + allowed because =coverage-core.el= is not the right long-term owner for a + generic argv/process error-reporting policy, and the helper is a prerequisite + for later shell-command hardening in VC, repo reconciliation, Hugo, and + language command modules. + +Behavior: + +- =program= is a non-empty string. +- =args= is a list of strings. +- The helper uses =process-file=, not a shell. +- =cwd=, when non-nil, temporarily binds =default-directory=. +- =stdin= is out of scope for the first implementation and must be nil. Add a + separate design note before supporting string/buffer/file stdin. +- Stdout and stderr are captured together in a temporary buffer unless a later + caller proves separated streams are needed. +- Exit status =0= is success even when stderr text exists. +- Exit status non-zero signals =user-error=. +- Exit status =0= with empty stdout returns =""=, not =nil=. +- =trim= nil returns raw output. =:trim t= uses =string-trim-right= so leading + output whitespace remains intact while common trailing newlines are removed. +- =error-message= is an optional caller label prepended to the generated error; + it does not replace command/status/output details. + +Likely later consumers: + +- hardened =vc-config.el= clone command, +- =reconcile-open-repos.el= repository scans, +- =hugo-config.el= deploy/build commands, +- language compile/test helpers where argv execution is practical. + +Tests: + +- success returns stdout, +- non-zero status signals =user-error=, +- error includes argv/status/output, +- cwd is honored, +- empty output behavior is defined, +- no shell interpolation occurs. + +Table grouping: + +- The =cj/process-output-or-error= and =cj/git-output-or-error= rows are one + extraction commit. The git helper should be a thin wrapper over the generic + process helper. + +** Group 4: File/Path Context And Safety + +Home: =system-lib.el= for simple predicates; keep test-only setup helpers in +=testutil-general.el=. + +Proposed API: + +#+begin_src emacs-lisp +(defun cj/file-from-context (&optional explicit-filename) + "Return a file path from explicit input, current buffer, or Dired point.") + +(defun cj/path-assert-in-directory (path directory) + "Signal an error unless PATH is inside DIRECTORY.") + +(defun cj/safe-recursive-delete-root-p (dir) + "Return non-nil when DIR is specific enough to delete recursively.") +#+end_src + +Policy: + +- Prefer built-in =file-in-directory-p= directly unless the caller needs a named + project policy. +- Extract =cj/file-from-context= early because it is a useful command helper and + already lives in a too-broad command module. +- Extract deletion safety only when a production destructive command is ready to + consume it. The test harness can continue using test-local names until then. + +First consumers: + +- =system-utils.el= =cj/open-file-with-command= and =cj/xdg-open=. +- =external-open.el= and =dirvish-config.el= once their file-open behavior is + aligned. +- Production destructive/deploy commands only after policy review. + +Behavior: + +- =cj/file-from-context= resolves in priority order: explicit filename, + current =buffer-file-name=, Dired file at point. +- It returns =nil= rather than prompting. Interactive commands decide whether + to prompt or signal. +- It may declare Dired functions but must not require a Dired implementation + package at load time. +- =cj/path-assert-in-directory= signals =user-error= with both paths in the + message. +- =cj/safe-recursive-delete-root-p= is not implemented until a production + destructive caller is ready to adopt it. + +Tests: + +- explicit filename wins, +- buffer file fallback, +- Dired point fallback, +- nil when no context exists, +- path containment handles =..= and symlinks according to documented policy, +- destructive safe-root rejects =/=, =~/=, =temporary-file-directory=, + =user-emacs-directory=, and =default-directory=. + +** Group 5: External Open Command Resolution + +Home: =external-open.el=. + +Proposed API: + +#+begin_src emacs-lisp +(defun cj/external-open-command () + "Return the platform default opener command.") + +(defun cj/external-open-launcher-p (command) + "Return non-nil when COMMAND should be detached as a desktop launcher.") +#+end_src + +Decision: + +- =external-open.el= owns platform opener command resolution because this is + workflow policy, not a foundation predicate. +- =external-open.el= may require =host-environment= for predicates. +- =host-environment.el= remains predicate-only. +- =system-lib.el= does not learn external-open workflow semantics. +- Have =dirvish-config.el= call that owner rather than duplicating OS cases. + +Behavior: + +- =cj/external-open-command= returns a command string or signals =user-error= + for unsupported hosts. +- The Linux default is =xdg-open=, macOS is =open=, and Windows is =start=. + A future helper for explicitly opening folders in Explorer is out of scope + for this group. +- =cj/external-open-launcher-p= returns boolean and has no side effects. +- The helpers only resolve commands; callers remain responsible for choosing + =call-process=, =start-process=, or a shell fallback. + +Tests: + +- Linux returns =xdg-open=, +- macOS returns =open=, +- Windows returns =start=, +- unsupported host errors clearly, +- launcher predicate handles =xdg-open=, =open=, =start=. + +** Group 6: Org-Safe Text + +Home: =cj-org-text.el= from first extraction. + +This is also the first topic library to land; the extraction commit +demonstrates the topic-library header pattern from [[*Library File Header Standard][Library File Header +Standard]]. + +Proposed API: + +#+begin_src emacs-lisp +(defun cj/org-sanitize-body-text (text) + "Prevent external body TEXT from creating unintended Org headings.") + +(defun cj/org-sanitize-property-value (text) + "Flatten TEXT for safe use as an Org property value.") + +(defun cj/org-sanitize-heading (text) + "Flatten TEXT for safe use as an Org heading.") +#+end_src + +Source: + +- =calendar-sync--sanitize-org-body= +- =calendar-sync--sanitize-org-property-value= +- =calendar-sync--sanitize-org-heading= + +Likely consumers: + +- =calendar-sync.el= event headings/properties/body, +- =org-webclipper.el= clipped page titles/content, +- =ai-conversations.el= model output persisted into Org, +- =mail-config.el= mail subjects inserted into capture/templates, +- future external ingest workflows. + +Justification: + +- This is a speculative extraction with one current concrete consumer family. + It is allowed because =calendar-sync.el= is not the right long-term owner for + generic external-text Org safety, the helpers are string-only, and several + external-ingest workflows need the same policy. + +Important distinction: + +- =calendar-sync--unescape-ics-text= should stay in =calendar-sync.el= because + it is ICS-specific. +- =calendar-sync--strip-html= may become =cj/text-strip-html= later, but only + with a docstring that says it is a lightweight regex cleanup, not a full HTML + parser. + +Behavior: + +- Nil input returns nil. +- Body text preserves line breaks but replaces leading Org heading stars with + dashes so external text cannot create outline entries. +- Property values and headings flatten newlines and collapse internal + whitespace to a single space. +- Heading sanitization composes body sanitization and property-value + flattening. +- These helpers do not parse Org. They are string guards for external text + before insertion into Org structures. + +Tests: + +- Move the existing calendar sanitizer tests to the new helper names. +- Add consumer tests showing calendar output still sanitizes correctly. +- Add webclipper/AI/mail tests only when those modules are migrated. + +** Group 7: Cache With TTL And Invalidation + +Home: =cj-cache.el=, not =system-lib.el=, once implemented. + +Current patterns: + +- =modeline-config.el=: per-buffer VC cache with key/time/value/set-p and + after-save/after-revert invalidation. +- =org-agenda-config.el=: global agenda-file cache with TTL and building flag. +- =org-refile-config.el=: global refile-target cache with TTL and building + flag. + +Proposed API shape: + +#+begin_src emacs-lisp +(cl-defstruct cj/cache + key value timestamp set-p ttl building-p) + +(defun cj/cache-valid-p (cache key &optional now) + "Return non-nil when CACHE has a value for KEY that has not expired.") + +(defun cj/cache-get-or-rebuild (cache key rebuild-fn &optional force) + "Return CACHE value for KEY or rebuild it with REBUILD-FN.") + +(defun cj/cache-clear (cache) + "Clear CACHE state.") +#+end_src + +This API is only illustrative. The real design must decide whether caches are: + +- structs stored in one variable, +- plists, +- closures, +- several caller-owned variables with helper predicates. + +Recommendation: + +- Do not extract cache helpers first. +- Behavioral normalization (lifecycle alignment between agenda/refile caches) + is owned by init-load-graph Phase 6. This project extracts the shared pattern + into =cj-cache.el= once that lifecycle alignment lands, or in parallel if the + design addendum proves the API can drive the alignment. +- Then decide whether modeline's buffer-local cache can use the same library or + should remain specialized. +- Phase 5 step 1 produces =docs/design/cache-helper-design.org=. Until that + file exists, =cj-cache.el= must not be created. The addendum is the + prerequisite for any cache extraction commit. + +Tests: + +- valid cache hit, +- forced rebuild, +- TTL expiration, +- nil value can be cached distinctly from "not set", +- rebuild flag is cleared on errors, +- hook invalidation clears only intended cache scope. + +** Group 8: Logging And Warnings + +Home: =system-lib.el=. + +Proposed API: + +#+begin_src emacs-lisp +(defun cj/message-log-only (format-string &rest args) + "Append a formatted message to *Messages* without minibuffer echo.") + +(cl-defun cj/display-warning-once (type message &key level key) + "Display warning MESSAGE once for KEY.") +#+end_src + +Recommendation: + +- Rename =cj/log-silently= to =cj/message-log-only= with an obsolete alias when + this low-priority helper is touched. Do not put this rename in the first + extraction wave. +- Do not add =cj/display-warning-once= until there is repeated once-only warning + behavior. For ordinary warnings, =display-warning= is already readable. + +Behavior: + +- =cj/message-log-only= preserves the current =cj/log-silently= behavior: + append one formatted message to =*Messages*= without echoing in the + minibuffer, ensure the message starts on its own line, and ensure the buffer + ends with a newline. +- =cj/display-warning-once= is not implemented until a second caller proves the + need. When implemented, duplicate suppression is process-local and keyed by + =(type key)=. =:level= defaults to =:warning=. + +Tests: + +- log-only inserts into =*Messages*=, +- warning-once suppresses duplicates by key, +- warning-once does not suppress unrelated warnings. + +** Group 9: File Content Helpers + +Home: =system-lib.el= only if reused. + +Potential API: + +#+begin_src emacs-lisp +(defun cj/read-file-string (file) + "Return FILE contents as a string.") + +(defun cj/write-file-string (file contents) + "Write CONTENTS to FILE, creating parents if requested by option.") +#+end_src + +Source: + +- =cj/theme-read-file-contents= +- =cj/theme-write-file-contents= + +Recommendation: + +- Defer. The theme helpers are small and theme-specific today. +- Extract only when another module needs identical read/write behavior. + +* Functions To Leave Alone For Now + +- =calendar-sync--parse-*=, =calendar-sync--expand-*=, and + =calendar-sync--unescape-ics-text=: calendar/ICS domain logic. +- =cj/--coverage-parse-simplecov= and =cj/--coverage-parse-diff-output=: + coverage domain parsing. +- =cj/--coverage-git-merge-base= and =cj/--coverage-git-diff=: coverage-specific + git policy after the low-level runner is extracted. +- =cj/modeline-string-cut-middle=: good utility shape, but currently only one + real caller. +- =cj/--generate-roam-slug= and org-roam formatting helpers: Org-roam workflow + semantics. These stay local because they are data/workflow-aware, unlike the + string-only Org sanitizers that only protect external text from becoming + unintended headings or malformed properties. +- text editing commands in =custom-*.el=: many are reusable commands, but they + are user-facing editing features rather than foundation utilities. +- package configuration helpers in =prog-*.el=: keep mode/package-specific + setup close to the package unless a generic policy emerges. +- test fixture builders in individual test files: keep local unless three or + more suites duplicate the same fixture shape. + +* Migration Phases + +Extraction commits should use conventional commit prefixes consistently: + +- =refactor:= for behavior-preserving helper moves/renames and call-site + migrations. +- =feat:= only when adding a new reusable helper for a new user-visible + capability. +- =test:= for test-only follow-up work. +- =docs:= for spec, inventory, and design addendum updates. + +** Phase 1: Inventory And Tags + +Create an inventory of private helpers across =modules/=. + +Inventory artifact: + +- Create =docs/design/utility-inventory.org=. +- Use an Org table with the fields below. +- Scope it to the candidate table in this spec plus new candidates discovered + during module walkthroughs. It is not required to list every private helper + across the whole codebase before Phase 2 can start. +- Treat the inventory as living documentation. Cleared high-priority candidates + may move to Phase 2 before the whole inventory is complete. +- This inventory is independent from the module-shape inventory maintained by + [[file:init-load-graph.org][init-load-graph.org]]. The two projects may walk the same files, but they + record different facts in separate artifacts. + +For each helper record: + +- current symbol, +- file, +- public/private status, +- dependencies, +- side effects, +- candidate home, +- proposed name, +- real consumers, +- test file(s), +- extraction priority, +- decision: extract, defer, keep, or replace with built-in. + +Audit output: + +- An =Audit= row produces an inventory decision: =Migrate=, =Leave=, or + =Defer=. +- =Migrate= decisions should create or update a concrete =todo.org= task. +- =Leave= and =Defer= decisions should record the rationale in the inventory so + the same audit is not repeated later. + +Exit criteria: + +- Every candidate in the table above is represented. +- Each candidate has at least one specific next action. +- No code behavior changes. + +** Phase 2: Low-Risk Foundation Helpers + +Extract helpers that are string/path/process-light and either have direct +consumers or are explicitly justified as wrong-layer speculative extractions in +this spec. + +Suggested order: + +1. =cj/executable-find-or-warn= +2. =cj/shell-quote-argument-readable= +3. =cj/process-output-or-error= and =cj/git-output-or-error= +4. =cj/file-from-context= + +Exit criteria: + +- Each extraction has moved or added focused tests. +- Consumer modules explicitly require =system-lib=. +- Full targeted tests pass after each extraction. +- Startup does not gain new package dependencies. +- Existing =use-package :if= checks are not migrated away from built-in + predicates unless a separate load-order task explicitly requires it. + +** Phase 3: Org-Safe Text Helpers + +Extract the calendar Org sanitizers and migrate calendar first. + +Then migrate consumers one at a time: + +- =org-webclipper.el=, +- =ai-conversations.el=, +- =mail-config.el= if mail-to-org behavior needs it. + +Exit criteria: + +- Existing calendar sanitizer tests pass under new helper names. +- Calendar integration tests still pass. +- New consumers have behavior tests showing external text cannot create + unintended headings/properties. + +** Phase 4: External Open Consolidation + +Pick a single owner for default system opener resolution. + +Then migrate: + +- =system-utils.el=, +- =dirvish-config.el=, +- =external-open.el=. + +Exit criteria: + +- One platform-opener decision point exists. +- Dired/Dirvish/current-buffer open workflows still work. +- Host tests cover Linux/macOS/Windows branches via predicate stubs. + +** Phase 5: Cache Abstraction + +Do this after simpler extractions, because cache abstraction is riskier. + +Suggested order: + +1. Write a Phase 5 design addendum with the exact cache API. The output of this + step is a design document, not code. +2. Normalize agenda/refile cache code first. +3. Add tests for rebuild, TTL, nil cache values, and error cleanup. +4. Consider modeline VC cache only after global cache behavior is stable. + +Exit criteria: + +- Agenda/refile behavior is unchanged. +- Building flags clear on errors. +- Batch startup does not start extra timers/processes. +- Cache helper does not obscure caller-specific rebuild logic. + +** Phase 6: Deferred And Opportunistic Helpers + +Consider lower-priority helpers only when a second consumer appears: + +- =cj/string-truncate-middle=, +- =cj/read-file-string= / =cj/write-file-string=, +- =cj/with-timer=, +- recursive file deletion helpers, +- warning-once helpers. + +* Test Strategy + +- Keep the project convention of focused helper tests. +- Name new tests after the library/helper, for example: + - =tests/test-system-lib-executable-find-or-warn.el= + - =tests/test-system-lib-shell-quote-argument-readable.el= + - =tests/test-system-lib-process-output-or-error.el= + - =tests/test-system-lib-file-from-context.el= + - =tests/test-cj-org-text-sanitize-heading.el= +- Move unit tests with extracted helpers. +- Keep consumer tests that prove the original workflow still calls the helper + correctly. +- Stub side-effectful primitives with =cl-letf=: + - =executable-find=, + - =display-warning=, + - =process-file=, + - Dired file-at-point helpers, + - host predicates. +- For each extraction commit, run targeted tests for the helper and each touched + consumer. Run full =make test= before marking the task =VERIFY=. + +Interactive coverage: + +- For each migrated consumer with a keybinding, add or keep a test asserting + =(key-binding (kbd "..."))= resolves to the intended command symbol. +- For non-trivial interactive flows such as =completing-read= prompts or + confirmation dialogs, use =with-simulated-input= where the helper is reachable + in batch. +- Use =execute-kbd-macro= for non-graphical keypress flows where it is simpler + than stubbing command internals. +- Mark visual-only checks, such as modeline appearance, theme rendering, and + font rendering, as manual smoke checks in the load-graph project rather than + utility helper tests. + +Coverage measurement: + +- Extracted helpers should meet the project's utility coverage target + (currently 90%) measured with =make coverage=. +- Phase 5 cache work should meet the same target and include explicit + unwind/error-path tests for rebuilding flags and invalidation cleanup. + +Tooling: Cask, ert-runner, buttercup: + +- Keep this project on bare =emacs --batch= plus ERT because this repository is + a personal Emacs configuration, not a redistributable package. +- =with-simulated-input= is acceptable as a test-only dependency for + interactive coverage when a migrated helper is reachable through a command + path. +- Cask, ert-runner, buttercup, and ecukes are out of scope unless a future task + gives a concrete reason to adopt them. + +Header validation: + +- Add a smoke test that asserts =modules/system-lib.el= and any future + =modules/cj-*.el= library file declares the required library header lines. + +Test relocation policy: + +- The extraction commit moves the helper, helper unit tests, consumer call + sites, and consumer call-site test updates together. +- Empty old helper test files are deleted in the same commit. +- If an old consumer test file still has consumer behavior coverage, keep it + and remove only the helper-specific tests. +- Avoid commits that only rename tests without moving behavior unless the rename + is too large to review with the helper extraction. + +* Acceptance Criteria + +This project is complete when: + +- =system-lib.el= has a documented role and contains only foundation-safe + helpers. +- Each extracted helper has a stable public name, tests, and explicit consumer + =require= statements. +- Feature modules no longer own generic executable lookup, process execution, + shell-readable quoting, or Org-safe text sanitation. +- External open command resolution has a single owner. +- Agenda/refile/modeline cache duplication has either been intentionally + consolidated or explicitly deferred with rationale. +- No helper extraction introduces package/network/timer side effects at load + time. +- Phase 2 and Phase 3 migrations record =emacs-init-time= against a phase + baseline; regressions around 25 ms or more should be investigated and + explained. +- Full =make test= passes after the final migration. + +* Risks And Mitigations + +** Risk: Premature abstraction + +Mitigation: + +- Require at least two real consumers. +- Keep "defer" as a valid inventory decision. +- Avoid helpers named around vague concepts such as "do thing safely." + +** Risk: Foundation module becomes too broad + +Mitigation: + +- Keep =system-lib.el= dependency-light. +- Split topic libraries when a helper family becomes stateful or domain-specific. +- Track every new =require= added to =system-lib.el=. + +** Risk: Behavior changes during rename + +Mitigation: + +- Move tests first where practical. +- Preserve old public symbols temporarily with aliases only when needed. +- Change one helper family per commit. +- Rename commits that preserve a one-cycle alias use =refactor:= and mention + the alias in the commit body; the alias does not need a separate commit. + +** Risk: Warnings become noisy at startup + +Mitigation: + +- Use warning helpers for user-invoked missing features. +- Keep optional package =:if= checks quiet unless the user explicitly enabled + the feature. +- Do not warn for every absent language tool on startup. + +** Risk: Helper calls in =use-package :if= create new load-order requirements + +Mitigation: + +- Do not migrate =use-package :if= clauses in this project. Keep built-in + predicates such as =executable-find= there unless a load-graph task handles + the ordering explicitly. +- Migrate function-body, command-body, and =:config= callers first, where + =require 'system-lib= can be ordinary and local. +- If a future migration needs a helper in =:if=, document the load-order + prerequisite in that commit and ensure =system-lib= is required before the + =use-package= form is macroexpanded/evaluated. + +** Risk: Process helper hides security decisions + +Mitigation: + +- Prefer argv APIs over shell command strings. +- Keep shell-string helpers clearly separate from process-file helpers. +- Document when a caller intentionally uses a shell. + +** Risk: Helper API turns out to be wrong + +Mitigation: + +- Revert the extraction commit, restore the source-module helper name and tests, + and file a redesign task. +- Do not patch a vague or wrong foundation API in place after consumers have + started migrating; stabilize the API before the second wave of consumers. + +* Open Questions + +- Should =system-lib.el= eventually become only an aggregator requiring topic + libraries, or remain the primary helper file? +- Should =system-utils.el= and =config-utilities.el= remain separate command + modules after library extraction? Default answer for this project: keep them + separate; revisit only during the load-graph refactor. +- What should the cache helper represent: a struct, a plist, closures, or + caller-owned variables plus helper predicates? + +Closed alias check: + +- A local search across =/home/cjennings/code=, =/home/cjennings/projects=, + =/home/cjennings/go=, and this repository found no uses of + =cj/executable-exists-p= or =cj/log-silently= outside this Emacs + configuration. Alias decisions are therefore for in-repo compatibility and + user muscle memory, not external package consumers. + +* Recommended First Three Commits + +Phase 2 also extracts =cj/file-from-context= as commit 4. It is not in this +first-three list because it has direct multi-consumer pressure and is not a +speculative extraction; the three commits below are the speculative or +high-policy commits that need extra care. + +1. Extract =cj/executable-find-or-warn= to =system-lib.el=. + - Migrate =mail-config.el=. + - Add =tests/test-system-lib-executable-find-or-warn.el=. + - Keep optional language/package checks unchanged. +2. Extract =cj/shell-quote-argument-readable= to =system-lib.el=. + - Migrate =dev-fkeys.el=. + - Move F6 shell-quote tests or add focused system-lib tests. + - Do not replace all =shell-quote-argument= callers yet. + - This is a wrong-layer speculative extraction; record expected future + consumers in the inventory. +3. Extract =cj/process-output-or-error= and =cj/git-output-or-error=. + - Migrate =coverage-core.el= only. + - Keep coverage-specific git wrappers in =coverage-core.el=. + - Add tests that stub =process-file=. + - This is a wrong-layer speculative extraction; record expected future + consumers in the inventory. + +These give the project useful proof points without touching stateful cache +behavior or broader load-order mechanics. |
