aboutsummaryrefslogtreecommitdiff
path: root/.claude/scripts/coverage-summary.el
blob: eb30c663334c0282029d73011944c2603a70ff54 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
;;; coverage-summary.el --- Whole-project coverage summary from a SimpleCov report -*- lexical-binding: t; -*-

;;; Commentary:
;; Batch helper for `make coverage-summary'.  After `make coverage' writes an
;; undercover SimpleCov JSON report, this prints a per-file table, a project
;; number, and the source files present on disk but absent from the report.
;;
;; The value here is the missing-file detection: a module no test imports never
;; appears in the SimpleCov output, so it silently fails to drag the number
;; down.  This script counts such a file as 0% and weights the project number
;; by file rather than by line, so untested modules are visible.
;;
;; Self-contained on purpose — it ships into a project's =.claude/scripts/= and
;; must run with nothing but stock Emacs (`json' is built in).  The SimpleCov
;; JSON shape it parses is:
;;   { <suite>: { "coverage": { <abs-path>: [null | 0 | int, ...] } } }
;; where a null entry is a non-executable line, 0 is executable-but-unhit, and
;; any positive integer is a hit.  Data unions across multiple suite keys.
;;
;; CLI contract (mirrors the dotemacs original):
;;   emacs --batch -l coverage-summary.el \
;;     --eval '(cj/coverage-print-module-summary REPORT SRC-DIR PROJECT-ROOT)'

;;; Code:

(require 'json)
(require 'seq)

(defun cj/coverage-summary--parse-file (report-file)
  "Parse REPORT-FILE (SimpleCov JSON) into per-file (COVERED . TOTAL) counts.

Keys are absolute source-file paths.  TOTAL counts executable lines (numeric
entries); COVERED counts hit lines (entries greater than zero).  Data unions
across every top-level suite key.  Signals `user-error' when REPORT-FILE is
missing or malformed."
  (unless (file-exists-p report-file)
    (user-error "Coverage report not found: %s" report-file))
  (let* ((json-object-type 'hash-table)
         (json-array-type 'list)
         (json-key-type 'string)
         (data (condition-case err
                   (json-read-file report-file)
                 (error (user-error "Malformed coverage JSON in %s: %s"
                                    report-file (error-message-string err)))))
         ;; path -> (covered-set . total-set), line numbers held in hash sets so
         ;; unioning across suites never double-counts a shared line.
         (acc (make-hash-table :test 'equal)))
    (maphash
     (lambda (_suite section)
       (when (hash-table-p section)
         (let ((coverage (gethash "coverage" section)))
           (when (hash-table-p coverage)
             (maphash
              (lambda (path hits-list)
                (let* ((cell (or (gethash path acc)
                                 (puthash path
                                          (cons (make-hash-table :test 'eql)
                                                (make-hash-table :test 'eql))
                                          acc)))
                       (covered (car cell))
                       (total (cdr cell))
                       (line 1))
                  (dolist (hits hits-list)
                    (when (numberp hits)
                      (puthash line t total)
                      (when (> hits 0) (puthash line t covered)))
                    (setq line (1+ line)))))
              coverage)))))
     data)
    (let ((result (make-hash-table :test 'equal)))
      (maphash (lambda (path cell)
                 (puthash path
                          (cons (hash-table-count (car cell))
                                (hash-table-count (cdr cell)))
                          result))
               acc)
      result)))

(defun cj/coverage-summary--under-dir (table source-dir project-root)
  "Filter TABLE to files under SOURCE-DIR, re-keyed relative to PROJECT-ROOT."
  (let ((result (make-hash-table :test 'equal))
        (source-dir (file-name-as-directory (expand-file-name source-dir)))
        (project-root (file-name-as-directory (expand-file-name project-root))))
    (maphash
     (lambda (path counts)
       (let ((abs (expand-file-name path)))
         (when (string-prefix-p source-dir abs)
           (puthash (file-relative-name abs project-root) counts result))))
     table)
    result))

(defun cj/coverage-summary--source-files (source-dir project-root)
  "Return *.el files directly under SOURCE-DIR, relative to PROJECT-ROOT.
Sorted; compiled files and subdirectories are out of scope."
  (let ((source-dir (file-name-as-directory (expand-file-name source-dir)))
        (project-root (file-name-as-directory (expand-file-name project-root))))
    (sort (mapcar (lambda (p) (file-relative-name p project-root))
                  (directory-files source-dir t "\\.el\\'"))
          #'string<)))

(defun cj/coverage-summary--missing (tracked source-dir project-root)
  "Return source files present on disk but absent from TRACKED.
TRACKED is a list of project-relative paths (the report's keys under
SOURCE-DIR).  The difference is the set of files no test exercised."
  (seq-difference
   (cj/coverage-summary--source-files source-dir project-root)
   tracked
   #'string=))

(defun cj/coverage-summary--file-pct (covered total)
  "Return COVERED/TOTAL as a percentage.
A file with no executable lines (TOTAL 0) is 100% — nothing left uncovered."
  (if (> total 0) (/ (* 100.0 covered) total) 100.0))

(defun cj/coverage-summary--project-pct (report-file source-dir project-root)
  "Return the unit-weighted project coverage percentage.
Every tracked file contributes its own percentage; every source file missing
from REPORT-FILE contributes 0%.  The result is the mean over all files under
SOURCE-DIR, so an untested module drags the number down instead of vanishing."
  (let* ((tracked (cj/coverage-summary--under-dir
                   (cj/coverage-summary--parse-file report-file)
                   source-dir project-root))
         (keys (let (ks) (maphash (lambda (k _v) (push k ks)) tracked) ks))
         (missing (cj/coverage-summary--missing keys source-dir project-root))
         (score 0.0)
         (total-count (+ (hash-table-count tracked) (length missing))))
    (maphash (lambda (_k counts)
               (setq score (+ score (cj/coverage-summary--file-pct
                                     (car counts) (cdr counts)))))
             tracked)
    (if (> total-count 0) (/ score total-count) 0.0)))

(defun cj/coverage-summary-text (report-file source-dir project-root)
  "Return a whole-project coverage summary for SOURCE-DIR from REPORT-FILE."
  (let* ((tracked (cj/coverage-summary--under-dir
                   (cj/coverage-summary--parse-file report-file)
                   source-dir project-root))
         (rel-src (file-relative-name
                   (expand-file-name source-dir)
                   (file-name-as-directory (expand-file-name project-root))))
         (keys (let (ks) (maphash (lambda (k _v) (push k ks)) tracked) ks))
         (missing (cj/coverage-summary--missing keys source-dir project-root))
         (pct (cj/coverage-summary--project-pct report-file source-dir project-root)))
    (with-temp-buffer
      (insert (format "Coverage summary for %s\n\n" rel-src))
      (dolist (path (sort keys #'string<))
        (let* ((counts (gethash path tracked))
               (covered (car counts))
               (total (cdr counts)))
          (insert (format "  %6.1f%%  %s  (%d/%d lines)\n"
                          (cj/coverage-summary--file-pct covered total)
                          path covered total))))
      (insert (format "\nProject coverage: %.1f%% (%d tracked, %d missing, %d total; missing files count as 0%%)\n"
                      pct (hash-table-count tracked) (length missing)
                      (+ (hash-table-count tracked) (length missing))))
      (insert (format "\nNot in coverage report: %d file%s\n"
                      (length missing) (if (= 1 (length missing)) "" "s")))
      (if missing
          (progn
            (insert "These files had no coverage entry; they count as 0% in project coverage.\n")
            (dolist (path (sort missing #'string<))
              (insert (format "  %s\n" path))))
        (insert "Every source file appears in the coverage report.\n"))
      (buffer-string))))

(defun cj/coverage-print-module-summary (report-file source-dir project-root)
  "Print a whole-project coverage summary for SOURCE-DIR from REPORT-FILE."
  (princ "\n")
  (princ (cj/coverage-summary-text report-file source-dir project-root)))

(provide 'coverage-summary)
;;; coverage-summary.el ends here