diff options
| author | Craig Jennings <c@cjennings.net> | 2026-04-22 19:58:00 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-04-22 19:58:00 -0500 |
| commit | a97266c0e89ef8560824789063512d2613849fc9 (patch) | |
| tree | ba320ed3d2dfedebade1fa79ece01baaa4750bd3 /modules | |
| parent | ecca6c5809aa2945d593baae10308c0dcfe6ec17 (diff) | |
| download | dotemacs-a97266c0e89ef8560824789063512d2613849fc9.tar.gz dotemacs-a97266c0e89ef8560824789063512d2613849fc9.zip | |
feat(coverage): wire make coverage target + simplecov pipeline
Completes the coverage v1 pipeline by adding the Makefile target, the undercover driver script, the exclusion list, and the .gitignore entry. Uses simplecov JSON rather than LCOV as the collection format.
The LCOV vs simplecov choice: Undercover's :merge-report t option only supports simplecov. Since the pipeline runs tests per-file (matching test-unit's isolation pattern) and accumulates coverage across runs, merge-report is required. LCOV is better-supported by external coverage viewers, but for a primarily interactive workflow the on-disk format is an internal detail.
Other moves in this commit:
- Renamed cj/--coverage-parse-lcov to cj/--coverage-parse-simplecov and rewrote its tests for the JSON schema. Same signature, same semantics (file to set of covered lines), different parser.
- Renamed the backend protocol's :lcov-path key to :report-path, format-neutral and matching the renamed cj/--coverage-elisp-report-path function.
- The coverage target deletes modules/*.elc before running so undercover can instrument the .el sources. Without this, byte-compiled versions shadow the instrumentation and only a handful of pre-loaded modules end up with coverage data.
- Excluded tests/test-all-comp-errors.el from make coverage runs. That test byte-compiles every module, which fails under undercover's instrumentation. Excluded only from coverage. Normal make test still runs it.
- Updated docs/design/coverage.org to reflect the simplecov pivot with a historical note on why we moved off LCOV.
Verified end-to-end: make coverage produces .coverage/simplecov.json with 2717 of 4559 executable lines hit across 44 tracked modules.
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/coverage-core.el | 86 | ||||
| -rw-r--r-- | modules/coverage-elisp.el | 24 |
2 files changed, 58 insertions, 52 deletions
diff --git a/modules/coverage-core.el b/modules/coverage-core.el index f1e8ae88..2209b2f7 100644 --- a/modules/coverage-core.el +++ b/modules/coverage-core.el @@ -16,7 +16,7 @@ (defvar cj/coverage-backends nil "Registry of coverage backends in priority order. -Each entry is a plist with at least :name, :detect, :run, and :lcov-path. +Each entry is a plist with at least :name, :detect, :run, and :report-path. Use `cj/coverage-register-backend' to add or replace an entry.") (defvar-local cj/coverage-backend nil @@ -26,7 +26,7 @@ function in registration order. Typically set buffer-locally via `.dir-locals.el' to pin a specific backend.") (defun cj/coverage-register-backend (backend) - "Register BACKEND, a plist with :name, :detect, :run, :lcov-path. + "Register BACKEND, a plist with :name, :detect, :run, :report-path. Appends to `cj/coverage-backends' at the end, or replaces the existing entry with the same :name in its current position." (let ((name (plist-get backend :name))) @@ -65,46 +65,50 @@ Returns the backend plist, or nil when no backend matches." (funcall (plist-get backend :detect) root)) cj/coverage-backends)))) -(defun cj/--coverage-parse-lcov (file) - "Parse FILE as LCOV and return a hash table of covered lines. +(defun cj/--coverage-parse-simplecov (file) + "Parse FILE as a simplecov JSON report and return covered lines per file. Keys are source-file paths (strings). Values are hash tables whose -keys are line numbers (integers) that had a hit count greater than -zero. Only the SF, DA, and end_of_record fields are read; other -LCOV fields are ignored. Malformed DA lines are skipped silently. -Signals `user-error' if FILE does not exist." +keys are line numbers (integers) with a hit count greater than zero. +Lines marked nil (not executable) or 0 (executable but not hit) are +excluded. + +Simplecov JSON structure is: + { <test-name>: { \"coverage\": { <path>: [null | 0 | int, ...] } } } + +When the JSON contains multiple top-level test-name keys, coverage +data is unioned across them; useful for files produced with undercover's +`:merge-report t' option that accumulate runs under a shared key, and +also for defensive handling of unexpected multi-key shapes. + +Signals `user-error' if FILE does not exist or contains malformed JSON." (unless (file-exists-p file) - (user-error "LCOV file not found: %s" file)) - (let ((result (make-hash-table :test 'equal)) - (current-file nil) - (current-lines nil)) - (with-temp-buffer - (insert-file-contents file) - (goto-char (point-min)) - (while (not (eobp)) - (let ((line (buffer-substring-no-properties - (line-beginning-position) (line-end-position)))) - (cond - ((string-prefix-p "SF:" line) - (setq current-file (substring line 3)) - (setq current-lines (make-hash-table :test 'eql))) - ((string-prefix-p "DA:" line) - (when current-lines - (let* ((rest (substring line 3)) - (parts (split-string rest ",")) - (line-str (car parts)) - (hits-str (cadr parts)) - (line-num (and line-str (string-match-p "\\`[0-9]+\\'" line-str) - (string-to-number line-str))) - (hits (and hits-str (string-match-p "\\`[0-9]+\\'" hits-str) - (string-to-number hits-str)))) - (when (and line-num hits (> hits 0)) - (puthash line-num t current-lines))))) - ((string= line "end_of_record") - (when current-file - (puthash current-file current-lines result)) - (setq current-file nil - current-lines nil)))) - (forward-line 1))) + (user-error "Simplecov report not found: %s" file)) + (require 'json) + (let* ((json-object-type 'hash-table) + (json-array-type 'list) + (json-key-type 'string) + (data (condition-case err + (json-read-file file) + (error (user-error "Malformed simplecov JSON in %s: %s" + file (error-message-string err))))) + (result (make-hash-table :test 'equal))) + (maphash + (lambda (_test-name section) + (when (hash-table-p section) + (let ((coverage (gethash "coverage" section))) + (when (hash-table-p coverage) + (maphash + (lambda (path hits-list) + (let ((lines (or (gethash path result) + (make-hash-table :test 'eql))) + (line-num 1)) + (dolist (hits hits-list) + (when (and (numberp hits) (> hits 0)) + (puthash line-num t lines)) + (setq line-num (1+ line-num))) + (puthash path lines result))) + coverage))))) + data) result)) (defconst cj/--coverage-hunk-header-regexp @@ -180,7 +184,7 @@ Signals `user-error' for any other SCOPE." (defun cj/--coverage-intersect (covered changed) "Combine COVERED (LCOV) with CHANGED (git diff) into per-file records. COVERED and CHANGED are each hash tables from file path to a hash table -of line numbers (as built by `cj/--coverage-parse-lcov' and +of line numbers (as built by `cj/--coverage-parse-simplecov' and `cj/--coverage-parse-diff-output'). Either may be nil, in which case the result is an empty list. diff --git a/modules/coverage-elisp.el b/modules/coverage-elisp.el index 63d89906..048c81dd 100644 --- a/modules/coverage-elisp.el +++ b/modules/coverage-elisp.el @@ -13,8 +13,10 @@ ;; the callback is invoked with the LCOV path; on failure, the buffer ;; stays visible for the user to inspect. ;; -;; :lcov-path resolves to `<project-root>/.coverage/lcov.info', which -;; matches the path the Makefile's coverage target writes to. +;; :report-path resolves to `<project-root>/.coverage/simplecov.json', +;; which matches the path the Makefile's coverage target writes to. +;; The simplecov JSON format is used because undercover's `:merge-report' +;; support only covers simplecov, not LCOV. ;;; Code: @@ -23,9 +25,9 @@ (use-package undercover :defer t) -(defconst cj/--coverage-elisp-lcov-relative-path - ".coverage/lcov.info" - "Project-relative path to the LCOV file produced by `make coverage'.") +(defconst cj/--coverage-elisp-report-relative-path + ".coverage/simplecov.json" + "Project-relative path to the simplecov JSON produced by `make coverage'.") (defun cj/--coverage-elisp-project-root (&optional root) "Return ROOT or fall back to projectile's root or `default-directory'." @@ -44,14 +46,14 @@ The heuristic needs both (a) a Makefile, Eask, or Cask at ROOT and (or (file-expand-wildcards (expand-file-name "modules/*.el" root)) (file-expand-wildcards (expand-file-name "*.el" root))))) -(defun cj/--coverage-elisp-lcov-path (&optional root) - "Return the absolute path to the LCOV file for ROOT." - (expand-file-name cj/--coverage-elisp-lcov-relative-path +(defun cj/--coverage-elisp-report-path (&optional root) + "Return the absolute path to the simplecov JSON file for ROOT." + (expand-file-name cj/--coverage-elisp-report-relative-path (cj/--coverage-elisp-project-root root))) (defun cj/--coverage-elisp-run (callback) "Run `make coverage' asynchronously. -CALLBACK is invoked with the LCOV path when the build finishes +CALLBACK is invoked with the report path when the build finishes successfully. On failure, no callback is invoked and the compilation buffer stays visible so the user can read the error." (let* ((default-directory (cj/--coverage-elisp-project-root)) @@ -61,14 +63,14 @@ buffer stays visible so the user can read the error." (add-hook 'compilation-finish-functions (lambda (_buf status) (when (string-match-p "^finished" status) - (funcall callback (cj/--coverage-elisp-lcov-path)))) + (funcall callback (cj/--coverage-elisp-report-path)))) nil t)))) (cj/coverage-register-backend (list :name 'elisp :detect #'cj/--coverage-elisp-detect :run #'cj/--coverage-elisp-run - :lcov-path #'cj/--coverage-elisp-lcov-path)) + :report-path #'cj/--coverage-elisp-report-path)) (provide 'coverage-elisp) ;;; coverage-elisp.el ends here |
