Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
code_health.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Build the "code health" panel published alongside the Doxygen API docs.
3
4The panel answers one question: how manageable is this codebase, in numbers that
5mean the same thing on darktable and on Ansel. It is generated identically in both
6repositories so the two published sites can be read side by side.
7
8Inputs, all optional except the first — a missing tool degrades its own section to
9"not available" instead of failing the build:
10
11 doc/api/sqlite3/doxygen_sqlite3.db Doxygen's own symbol table (GENERATE_SQLITE3),
12 produced by a fast first Doxygen pass. Gives
13 symbols per file and the include graph.
14 lizard cyclomatic complexity (CCN) per function.
15 cppcheck static analysis without needing a build.
16 <clang-tidy report>.json/.txt clang-tidy findings, when a separate job that
17 can produce compile_commands.json has run.
18
19Outputs:
20
21 doc/code-health.md a Doxygen page (picked up by INPUT, themed, searchable)
22 doc/code-health.json the same numbers, machine-readable, for cross-repo diffing
23
24Usage:
25 python3 tools/code_health.py --project darktable --source-dir src \\
26 [--db doc/api/sqlite3/doxygen_sqlite3.db] [--clang-tidy-log FILE]
27"""
28
29import argparse
30import csv
31import json
32import os
33import re
34import shutil
35import sqlite3
36import subprocess
37import sys
38from collections import Counter, defaultdict
39
40# Vendored third-party code and dead trees, excluded everywhere so the numbers describe
41# the code each repository actually authors. Kept in step with the sonar.exclusions line
42# in .sonarcloud.properties.
43#
44# This is the UNION of what darktable and Ansel each need, so that this file stays
45# byte-identical in both repositories and "are the two panels measuring the same thing?"
46# is answerable with cmp(1). A path that exists in only one tree costs nothing in the
47# other.
48EXCLUDED_DIR_PARTS = [
49 "/external/", # both: vendored code that is NOT a submodule either
50 # (lua/, LuaAutoC/, cie_colorimetric_tables.c, ...)
51 "/apps/ansel-chart/", # Ansel: dead code, no build target compiles it
52]
53
54# Every git submodule is added to that list at startup, read from .gitmodules rather
55# than hardcoded. A submodule is an upstream project pinned at a commit: its
56# complexity, its defects and its size belong to whoever wrote it, and counting them
57# describes someone else's codebase. Reading the list means it cannot drift when a
58# release adds, drops or moves one - which is exactly what happens across a version
59# upgrade of the reference tree.
60
61
62def load_submodule_exclusions(repo_root="."):
63 """Extend EXCLUDED_DIR_PARTS with every path declared in .gitmodules."""
64 path = os.path.join(repo_root, ".gitmodules")
65 found = []
66 try:
67 with open(path, encoding="utf-8", errors="replace") as fh:
68 for line in fh:
69 line = line.strip()
70 if not line.startswith("path"):
71 continue
72 _key, _sep, value = line.partition("=")
73 value = value.strip().strip("/")
74 if value:
75 found.append(value)
76 except OSError:
77 return []
78 added = []
79 for sub in found:
80 part = "/" + sub.replace(os.sep, "/").strip("/") + "/"
81 if part not in EXCLUDED_DIR_PARTS:
83 added.append(sub)
84 return added
85
86
88 """Shell-glob form of the exclusion list, for tools that filter by pattern.
89
90 Derived on demand, never written out twice, so it always reflects the submodule
91 paths loaded from .gitmodules.
92 """
93 return tuple("*%s*" % part for part in EXCLUDED_DIR_PARTS)
94
95# What counts as production code: an ALLOWLIST, not a list of things to skip.
96#
97# Only these are compiled into the application and run on a user's machine. Everything
98# else in either repository - Python and shell helpers under tools/, YAML workflows,
99# CMake and build glue, Markdown documentation, XML and JSON resources - is developer
100# or build material. Measuring it reports the health of the toolbox rather than of the
101# software, and the two projects keep very differently sized toolboxes, so counting it
102# actively distorts the comparison.
103#
104# An allowlist is deliberate: anything new that appears in either tree is excluded
105# until someone decides it ships, rather than silently joining the measurements.
106SOURCE_SUFFIXES = (".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx", ".m", ".mm")
107
108# cloc identifies languages by content, not only by extension, so a helper script with
109# no suffix and a #!/usr/bin/python3 line is still reported as Python. The size section
110# therefore filters on cloc's own language name, using the same allowlist idea.
111PRODUCTION_LANGUAGES = frozenset((
112 "C", "C/C++ Header", "C++", "Objective-C", "Objective-C++",
113))
114
115
117 """True for a file that is compiled into the shipped application."""
118 return path.lower().endswith(SOURCE_SUFFIXES)
119
120
121def is_excluded(path):
122 """True for anything that must not be measured: vendored, dead, or not shipped."""
123 p = "/" + path.replace(os.sep, "/").lstrip("/")
124 if not is_production_file(p):
125 return True
126 return any(part in p for part in EXCLUDED_DIR_PARTS)
127
128
129def run(cmd, **kw):
130 """Run a command, returning (ok, stdout). Never raises on a non-zero exit."""
131 try:
132 r = subprocess.run(cmd, capture_output=True, text=True,
133 errors="replace", check=False, **kw)
134 return r.returncode == 0, r.stdout
135 except (OSError, subprocess.SubprocessError) as exc:
136 return False, str(exc)
137
138
139# --------------------------------------------------------------------------- symbols
140
141
142def collect_symbols(db_path):
143 """Symbols per file, from Doxygen's SQLite output.
144
145 memberdef.kind is Doxygen's own vocabulary: 'function', 'variable', 'typedef',
146 'macro definition', 'enumeration'. Note it is 'macro definition', not 'define'.
147 """
148 if not db_path or not os.path.exists(db_path):
149 return None
150 con = sqlite3.connect("file:%s?mode=ro" % db_path, uri=True)
151 try:
152 rows = con.execute(
153 """
154 SELECT p.name AS path, m.kind AS kind, COUNT(*) AS n
155 FROM memberdef m JOIN path p ON p.rowid = m.file_id
156 GROUP BY p.name, m.kind
157 """
158 ).fetchall()
159 except sqlite3.Error as exc:
160 sys.stderr.write("code_health: symbol query failed: %s\n" % exc)
161 return None
162 finally:
163 con.close()
164
165 per_file = defaultdict(Counter)
166 for path, kind, n in rows:
167 if is_excluded(path):
168 continue
169 per_file[path][kind] += n
170
171 out = []
172 for path, kinds in per_file.items():
174 {
175 "file": path,
176 "total": sum(kinds.values()),
177 "functions": kinds.get("function", 0),
178 "variables": kinds.get("variable", 0),
179 "typedefs": kinds.get("typedef", 0),
180 "macros": kinds.get("macro definition", 0),
181 "enums": kinds.get("enumeration", 0),
182 }
183 )
184 out.sort(key=lambda r: (-r["total"], r["file"]))
185 return out
186
187
188def include_edges(db_path):
189 """Every (including file, included file) pair inside this tree.
190
191 Doxygen's `includes` table is the same data its "included by" graphs are drawn
192 from, so the numbers derived here and the graphs on the file pages cannot drift
193 apart.
194 """
195 if not db_path or not os.path.exists(db_path):
196 return None
197 con = sqlite3.connect("file:%s?mode=ro" % db_path, uri=True)
198 try:
199 tables = {r[0] for r in con.execute(
200 "SELECT name FROM sqlite_master WHERE type IN ('table','view')")}
201 if "includes" not in tables:
202 return None
203 # Doxygen's `includes` table links a including file to an included one. Column
204 # names have moved between versions, so resolve them rather than assume.
205 cols = {r[1] for r in con.execute("PRAGMA table_info(includes)")}
206 src = "src_id" if "src_id" in cols else ("including_id" if "including_id" in cols else None)
207 dst = "dst_id" if "dst_id" in cols else ("included_id" if "included_id" in cols else None)
208 if not src or not dst:
209 return None
210 # path.local distinguishes files belonging to this tree (1) from system headers
211 # resolved outside it (0). Counting <stdlib.h>'s fan-in says nothing about how
212 # entangled this codebase is, so restrict both ends to local files.
213 rows = con.execute(
214 "SELECT ps.name, pd.name FROM includes i "
215 "JOIN path ps ON ps.rowid = i.%s JOIN path pd ON pd.rowid = i.%s "
216 "WHERE ps.local = 1 AND pd.local = 1" % (src, dst)
217 ).fetchall()
218 except sqlite3.Error as exc:
219 sys.stderr.write("code_health: include query failed: %s\n" % exc)
220 return None
221 finally:
222 con.close()
223
224 return [(a, b) for a, b in rows if not is_excluded(a) and not is_excluded(b)]
225
226
227def source_include_edges(repo_root, source_dir="src"):
228 """Build the include graph a second time, straight from the source text.
229
230 Doxygen only records an include it managed to RESOLVE, and resolution depends on
231 INCLUDE_PATH, on conditional compilation, and on which headers exist at doc-build
232 time. Measured on darktable 5.6 it missed 184 edges that plainly exist in the
233 files - platform headers behind #ifdef, mostly - while finding 133 the text does
234 not show, from generated headers. Each graph therefore contained a cyclic cluster
235 the other did not.
236
237 Neither is authoritative on its own, so the panel unions them. Resolution mirrors
238 the compiler: the including file's own directory first, then the source root.
239 """
240 root = os.path.abspath(repo_root)
241 src = os.path.join(root, source_dir)
242 if not os.path.isdir(src):
243 return []
244 known, files = set(), []
245 for dirpath, dirnames, filenames in os.walk(src):
246 rel_dir = "/" + os.path.relpath(dirpath, root).replace(os.sep, "/") + "/"
247 if any(part in rel_dir for part in EXCLUDED_DIR_PARTS):
248 dirnames[:] = []
249 continue
250 for name in filenames:
251 if name.lower().endswith(SOURCE_SUFFIXES):
252 rel = os.path.relpath(os.path.join(dirpath, name), root).replace(os.sep, "/")
253 known.add(rel)
254 files.append((rel, os.path.join(dirpath, name)))
255 pattern = re.compile(r'^\s*#\s*include\s+"([^"]+)"', re.M)
256 edges = set()
257 for rel, full in files:
258 try:
259 with open(full, encoding="utf-8", errors="replace") as fh:
260 text = fh.read()
261 except OSError:
262 continue
263 base = os.path.dirname(rel)
264 for m in pattern.finditer(text):
265 inc = m.group(1)
266 for cand in (os.path.normpath(os.path.join(base, inc)).replace(os.sep, "/"),
267 os.path.normpath(os.path.join(source_dir, inc)).replace(os.sep, "/")):
268 if cand in known:
269 if cand != rel:
270 edges.add((rel, cand))
271 break
272 return sorted(edges)
273
274
276 """How many files include each header, directly.
277
278 This is the number behind the "included by" graphs: the fan-in of a header, and
279 the single clearest measure of how entangled a codebase's headers are.
280 """
281 if not edges:
282 return None
283 fan_in = Counter()
284 for _including, included in edges:
285 fan_in[included] += 1
286 return [{"file": f, "included_by": n} for f, n in fan_in.most_common()]
287
288
289def collect_reach(edges):
290 """Transitive reach, in both directions. This is where a god header shows up.
291
292 Cycle counts cannot express the damage darktable.h does, and this was measured
293 before the section was written: darktable.h reaches only 14 files downstream, so
294 at most 14 could ever cycle with it and exactly 3 do. Its 4-file cluster is
295 correct and says almost nothing.
296
297 The number that matters is the other direction. 552 of 741 files reach it, so
298 three quarters of the codebase depends on that one header, transitively. Editing
299 it rebuilds and re-reviews nearly everything, and no cycle metric will say so.
300
301 dependents how many files end up depending on this header, directly or not.
302 High means expensive to change and hard to reason about.
303 depth how many headers a translation unit drags in transitively. High
304 means slow builds and a file whose real interface is unknowable
305 from its own include list.
306 """
307 if not edges:
308 return None
309 succ, pred = defaultdict(set), defaultdict(set)
310 nodes = set()
311 for a, b in edges:
312 succ[a].add(b)
313 pred[b].add(a)
314 nodes.add(a)
315 nodes.add(b)
316
317 def closure(adj, start):
318 seen = set()
319 stack = [start]
320 while stack:
321 u = stack.pop()
322 for v in adj.get(u, ()):
323 if v not in seen:
324 seen.add(v)
325 stack.append(v)
326 seen.discard(start)
327 return seen
328
329 total = len(nodes)
330
331 # ---- propagation cost and core/periphery (MacCormack, Baldwin & Rusnak)
332 #
333 # Visibility fan-out is everything a file can reach, fan-in everything that can
334 # reach it. Propagation cost is the mean fan-out as a share of the system: the
335 # probability that a change to a random file can, in principle, reach a random
336 # other one. It is the single number this whole section is circling.
337 #
338 # The CORE is the largest group of files that all reach one another - a cyclic
339 # group, so by definition it has no internal layering. Everything else is
340 # classified against the core's thresholds: SHARED files are depended on as
341 # widely as the core but depend on less, PERIPHERAL files are on neither end,
342 # and CONTROL files reach as widely as the core without being depended upon.
343 # A healthy system has a small core and a large periphery.
344 vfo = {n: len(closure(succ, n)) for n in nodes}
345 vfi = {n: len(closure(pred, n)) for n in nodes}
346 propagation = round(100.0 * sum(vfo.values()) / float(max(1, total * total)), 2)
347
348 cyclic = defaultdict(list)
349 for n in nodes:
350 both = closure(succ, n) & closure(pred, n)
351 if both:
352 cyclic[frozenset(both | {n})].append(n)
353 core_set = max(cyclic, key=len) if cyclic else frozenset()
354 core_vfi = max((vfi[n] for n in core_set), default=0)
355 core_vfo = max((vfo[n] for n in core_set), default=0)
356 buckets = Counter()
357 for n in nodes:
358 if n in core_set:
359 buckets["core"] += 1
360 elif vfi[n] >= core_vfi and vfo[n] < core_vfo:
361 buckets["shared"] += 1
362 elif vfi[n] < core_vfi and vfo[n] >= core_vfo:
363 buckets["control"] += 1
364 else:
365 buckets["peripheral"] += 1
366
367 headers = [n for n in nodes if n.lower().endswith((".h", ".hpp", ".hxx"))]
368 dependents = []
369 for h in headers:
370 n = len(closure(pred, h))
371 # What this header forces on everyone who includes it. A header should
372 # include only what its own declarations need; anything beyond that is a
373 # supply line its consumers never asked for and cannot see. Multiplying the
374 # two gives the (file, header) pairs this one header is responsible for
375 # across the whole tree - the weight it actually imposes.
376 drags = len(closure(succ, h))
377 dependents.append({"file": h, "dependents": n,
378 "share": round(100.0 * n / max(1, total), 1),
379 "drags_in": drags, "burden": n * drags})
380 dependents.sort(key=lambda r: -r["dependents"])
381 by_burden = sorted(dependents, key=lambda r: -r["burden"])[:30]
382
383 sources = [n for n in nodes if not n.lower().endswith((".h", ".hpp", ".hxx"))]
384 depth = [{"file": c, "headers_pulled": len(closure(succ, c))} for c in sources]
385 depth.sort(key=lambda r: -r["headers_pulled"])
386 counts = sorted(r["headers_pulled"] for r in depth)
387 mean = sum(counts) / float(len(counts)) if counts else 0
388
389 return {
390 "files": total,
391 "propagation_cost": propagation,
392 "core_size": len(core_set),
393 "core_share": round(100.0 * len(core_set) / max(1, total), 1),
394 "core_files": sorted(core_set)[:30],
395 "buckets": dict(buckets),
396 "top_dependents": dependents[:30],
397 "top_burden": by_burden,
398 "headers_over_half": sum(1 for r in dependents if r["share"] >= 50.0),
399 "headers_over_quarter": sum(1 for r in dependents if r["share"] >= 25.0),
400 "deepest": depth[:20],
401 "mean_headers_pulled": round(mean, 1),
402 "median_headers_pulled": counts[len(counts) // 2] if counts else 0,
403 "max_headers_pulled": counts[-1] if counts else 0,
404 }
405
406
407def _modularity(adj_w, degree, two_m, partition):
408 """Newman modularity Q of one partition of a weighted undirected graph."""
409 if two_m <= 0:
410 return 0.0
411 inner, deg = Counter(), Counter()
412 for u, nbrs in adj_w.items():
413 cu = partition[u]
414 deg[cu] += degree[u]
415 for v, w in nbrs.items():
416 if partition[v] == cu:
417 inner[cu] += w # counts each internal edge twice, as required
418 return sum(inner[c] / two_m - (deg[c] / two_m) ** 2 for c in deg)
419
420
421def _louvain(adj_w, degree, two_m, passes=12):
422 """Louvain community detection, first phase iterated to convergence.
423
424 Implemented here rather than pulled in, so the panel keeps needing nothing but
425 python3 on the runner. Only the local-moving phase is used - repeated to a fixed
426 point - which is enough to establish whether a better grouping than the directory
427 layout exists, without the graph-coarsening phase's bookkeeping.
428 """
429 partition = {n: n for n in adj_w}
430 comm_deg = dict(degree)
431 for _ in range(passes):
432 moved = False
433 for u in sorted(adj_w):
434 cu = partition[u]
435 ku = degree[u]
436 weights = Counter()
437 for v, w in adj_w[u].items():
438 if v != u:
439 weights[partition[v]] += w
440 comm_deg[cu] -= ku
441 best, gain = cu, weights.get(cu, 0) - comm_deg.get(cu, 0) * ku / two_m
442 for c, w in weights.items():
443 g = w - comm_deg.get(c, 0) * ku / two_m
444 if g > gain + 1e-12:
445 best, gain = c, g
446 comm_deg[best] = comm_deg.get(best, 0) + ku
447 if best != cu:
448 partition[u] = best
449 moved = True
450 if not moved:
451 break
452 return partition
453
454
455def collect_modularity(edges, source_dir="src"):
456 """Does the folder layout correspond to how the code is actually coupled?
457
458 The directories are treated as a proposed partition of the dependency graph and
459 scored with Newman modularity Q - the share of edges falling inside groups, minus
460 what random wiring of the same degrees would produce. Then a partition is derived
461 from the graph itself with Louvain and scored the same way.
462
463 The GAP between the two is the number that matters. If directories really were
464 modules, grouping by directory would be near-optimal and the gap would be small.
465 A large gap means the folders are drawers: the code clusters, but not along the
466 lines the tree is filed under.
467
468 Also reported without any clustering at all: the share of includes that stay
469 inside their own directory, which is the same question asked bluntly.
470 """
471 if not edges:
472 return None
473 adj_w = defaultdict(Counter)
474 for a, b in edges:
475 if a == b:
476 continue
477 adj_w[a][b] += 1 # undirected: coupling has no direction
478 adj_w[b][a] += 1
479 if not adj_w:
480 return None
481 degree = {n: sum(w.values()) for n, w in adj_w.items()}
482 two_m = float(sum(degree.values()))
483
484 dirs = {}
485 for n in adj_w:
486 dirs[n] = module_of(n, source_dir) or "(root)"
487 q_dir = _modularity(adj_w, degree, two_m, dirs)
488
489 derived = _louvain(adj_w, degree, two_m)
490 q_derived = _modularity(adj_w, degree, two_m, derived)
491
492 inside = sum(1 for a, b in edges
493 if module_of(a, source_dir) == module_of(b, source_dir))
494 clusters = Counter(derived.values())
495 sizes = sorted(clusters.values(), reverse=True)
496
497 # how far the derived grouping is from the filed one, in files that would move
498 best_match = {}
499 pair = defaultdict(Counter)
500 for n, c in derived.items():
501 pair[c][dirs[n]] += 1
502 for c, counts in pair.items():
503 best_match[c] = counts.most_common(1)[0][1]
504 agree = sum(best_match.values())
505
506 return {
507 "q_directories": round(q_dir, 3),
508 "q_derived": round(q_derived, 3),
509 "gap": round(q_derived - q_dir, 3),
510 "directories": len(set(dirs.values())),
511 "derived_clusters": len(clusters),
512 "largest_clusters": sizes[:10],
513 "intra_directory_includes": inside,
514 "total_includes": len(edges),
515 "intra_directory_share": round(100.0 * inside / max(1, len(edges)), 1),
516 "files_in_agreeing_cluster": agree,
517 "files": len(adj_w),
518 "agreement_share": round(100.0 * agree / max(1, len(adj_w)), 1),
519 }
520
521
523 """Fold in a header self-containment report, when one has been produced.
524
525 A header should compile on its own. One that does not is relying on its includer
526 having pulled something in first, which is the same defect as an unnecessary
527 include seen from the other side: the dependency is real but written nowhere.
528
529 Testing it needs a compiler and the project's include flags, so it is produced by
530 the code-health workflow, which already configures a build tree, and consumed here.
531 """
532 if not path or not os.path.exists(path):
533 return None
534 try:
535 with open(path, encoding="utf-8", errors="replace") as fh:
536 data = json.load(fh)
537 except (OSError, ValueError) as exc:
538 sys.stderr.write("code_health: self-containment report unreadable: %s\n" % exc)
539 return None
540 failing = [f for f in data.get("results", []) if not f.get("ok")]
541 total = len(data.get("results", []))
542 return {
543 "headers": total,
544 "self_contained": total - len(failing),
545 "share": round(100.0 * (total - len(failing)) / max(1, total), 1),
546 "failing": sorted((f["header"], f.get("first_error", "")[:120]) for f in failing)[:30],
547 "failing_count": len(failing),
548 }
549
550
551def collect_docs(db_path):
552 """How much of the API carries any documentation at all.
553
554 Doxygen records a brief and a detailed description per symbol, so the tree's own
555 documentation coverage is a query rather than an estimate. Counted over the same
556 production files as everything else.
557 """
558 if not db_path or not os.path.exists(db_path):
559 return None
560 con = sqlite3.connect("file:%s?mode=ro" % db_path, uri=True)
561 try:
562 rows = con.execute(
563 "SELECT p.name, m.kind, "
564 " TRIM(COALESCE(m.briefdescription,'')) || TRIM(COALESCE(m.detaileddescription,'')) "
565 "FROM memberdef m JOIN path p ON p.rowid = m.file_id"
566 ).fetchall()
567 except sqlite3.Error as exc:
568 sys.stderr.write("code_health: doc query failed: %s\n" % exc)
569 return None
570 finally:
571 con.close()
572
573 total, documented = Counter(), Counter()
574 per_file = defaultdict(lambda: [0, 0])
575 for path, kind, text in rows:
576 if is_excluded(path):
577 continue
578 total[kind] += 1
579 per_file[path][0] += 1
580 if (text or "").strip():
581 documented[kind] += 1
582 per_file[path][1] += 1
583 if not total:
584 return None
585
587 by_kind = [{"kind": k, "symbols": total[k], "documented": documented[k],
588 "share": round(100.0 * documented[k] / max(1, total[k]), 1)}
589 for k in sorted(total, key=lambda k: -total[k])]
590 undoc = sorted(((p, v[0] - v[1], v[0]) for p, v in per_file.items() if v[0] - v[1] > 0),
591 key=lambda r: -r[1])[:25]
592 return {
593 "symbols": n,
594 "documented": d,
595 "share": round(100.0 * d / max(1, n), 1),
596 "by_kind": by_kind,
597 "worst_files": [{"file": f, "undocumented": u, "symbols": t} for f, u, t in undoc],
598 }
599
600
601def collect_git(repo_root, days, per_file_ccn, max_files_per_commit=20):
602 """Evolution metrics: churn, hotspots, change coupling and ownership.
603
604 Process metrics predict defects better than static complexity does - complex code
605 nobody touches is harmless, complex code changed weekly is where the bugs are - and
606 none of the rest of this panel can see them, because they are not a property of the
607 code as it stands but of how it got there.
608
609 hotspot revisions x cyclomatic complexity. The prioritisation metric:
610 what to refactor first, rather than what is merely large.
611 change coupling files that keep changing together in the same commit. Some of
612 those pairs have no include edge between them at all, which is
613 coupling no static analysis can find.
614 ownership authors per file. Concentration is not automatically good or
615 bad - one author means fast decisions and a bus factor of one.
616
617 Commits touching more than max_files_per_commit production files are excluded from
618 the coupling counts only: a sweeping rename couples everything it touches to
619 everything else, which is an artefact of the commit rather than of the code. They
620 still count towards churn and ownership.
621 """
622 if not shutil.which("git"):
623 return None
624 fmt = "__COMMIT__%H\x1f%an"
625 ok, out = run(["git", "-C", repo_root, "log", "--since=%d.days.ago" % days,
626 "--no-merges", "--numstat", "--format=" + fmt])
627 if not out.strip():
628 sys.stderr.write("code_health: git log empty (shallow clone?)\n")
629 return None
630
631 revisions, churn = Counter(), Counter()
632 authors = defaultdict(set)
633 cochange = Counter()
634 commits = 0
635 current, author = [], None
636
637 def flush():
638 if not current:
639 return
640 for f in current:
641 revisions[f] += 1
642 authors[f].add(author)
643 if len(current) <= max_files_per_commit:
644 uniq = sorted(set(current))
645 for i in range(len(uniq)):
646 for j in range(i + 1, len(uniq)):
647 cochange[(uniq[i], uniq[j])] += 1
648
649 for line in out.splitlines():
650 if line.startswith("__COMMIT__"):
651 flush()
652 current = []
653 commits += 1
654 _h, _sep, author = line[len("__COMMIT__"):].partition("\x1f")
655 continue
656 parts = line.split("\t")
657 if len(parts) != 3:
658 continue
659 added, deleted, path = parts
660 if is_excluded(path):
661 continue
662 try:
663 churn[path] += int(added) + int(deleted)
664 except ValueError:
665 pass # binary file, recorded as "-"
666 current.append(path)
667 flush()
668 if not revisions:
669 return None
670
671 hotspots = []
672 for f, revs in revisions.items():
673 cx = per_file_ccn.get(f, 0)
674 if cx:
675 hotspots.append({"file": f, "revisions": revs, "ccn": cx,
676 "churn": churn.get(f, 0), "score": revs * cx})
677 hotspots.sort(key=lambda r: -r["score"])
678
679 coupled = []
680 for (a, b), n in cochange.items():
681 ra, rb = revisions[a], revisions[b]
682 conf = 100.0 * n / max(1, min(ra, rb))
683 if n >= 5 and conf >= 40.0:
684 coupled.append({"pair": "%s <-> %s" % (a, b), "together": n,
685 "confidence": round(conf, 0)})
686 coupled.sort(key=lambda r: (-r["together"], -r["confidence"]))
687
688 author_counts = sorted(len(v) for v in authors.values())
689 return {
690 "days": days,
691 "commits": commits,
692 "files_touched": len(revisions),
693 "total_churn": sum(churn.values()),
694 "hotspots": hotspots[:25],
695 "coupled": coupled[:25],
696 "coupled_total": len(coupled),
697 "single_author_files": sum(1 for c in author_counts if c == 1),
698 "mean_authors": round(sum(author_counts) / float(len(author_counts)), 2),
699 "max_authors": author_counts[-1] if author_counts else 0,
700 "most_revised": [{"file": f, "revisions": n, "churn": churn.get(f, 0)}
701 for f, n in revisions.most_common(15)],
702 }
703
704
705# ----------------------------------------------------------------------- layering
706
707
708# NOTE ON UNITS. There is deliberately no hand-declared layer table here any more.
709#
710# It used to rank src/ subdirectories - common below control, the GUI toolkit below
711# the pipeline, and so on - and count includes pointing the wrong way against it.
712# That was wrong twice over. It encoded one person's reading of the architecture, so
713# the metric partly measured its own author; and more fundamentally darktable does
714# not use subdirectories as modules. They are drawers: groupings of convenience with
715# no ownership or interface boundary, so "common is below control" is an assertion
716# the code never made and cannot support. Ansel is moving towards real modules, but
717# a measure that is meaningful on one side and meaningless on the other cannot
718# compare them.
719#
720# Everything below is derived from the include graph instead, and the primary unit
721# is the FILE, which needs no notion of module at all. Directory-level figures are
722# still reported, clearly labelled as an aggregation over drawers.
723
724def module_of(path, source_dir="src"):
725 """The module a file belongs to: its first path component under the source dir."""
726 p = path.replace(os.sep, "/")
727 marker = "/" + source_dir.strip("/") + "/"
728 if p.startswith(source_dir.strip("/") + "/"):
729 rest = p[len(source_dir.strip("/")) + 1:]
730 elif marker in p:
731 rest = p.split(marker, 1)[1]
732 else:
733 return None
734 parts = rest.split("/")
735 return parts[0] if len(parts) > 1 else "(root)"
736
737
738def strongly_connected(nodes, succ):
739 """Tarjan's SCC, iterative so a deep include chain cannot blow the stack."""
740 index, low, on_stack, stack, comps = {}, {}, set(), [], []
741 counter = [0]
742 for root in nodes:
743 if root in index:
744 continue
745 work = [(root, iter(succ.get(root, ())))]
746 index[root] = low[root] = counter[0]
747 counter[0] += 1
748 stack.append(root)
749 on_stack.add(root)
750 while work:
751 node, it = work[-1]
752 advanced = False
753 for nxt in it:
754 if nxt not in index:
755 index[nxt] = low[nxt] = counter[0]
756 counter[0] += 1
757 stack.append(nxt)
758 on_stack.add(nxt)
759 work.append((nxt, iter(succ.get(nxt, ()))))
760 advanced = True
761 break
762 if nxt in on_stack:
763 low[node] = min(low[node], index[nxt])
764 if advanced:
765 continue
766 work.pop()
767 if work:
768 low[work[-1][0]] = min(low[work[-1][0]], low[node])
769 if low[node] == index[node]:
770 comp = []
771 while True:
772 w = stack.pop()
774 comp.append(w)
775 if w == node:
776 break
777 comps.append(comp)
778 return comps
779
780
782 """Order nodes so that as few weighted edges as possible point backwards.
783
784 `edges` maps (a, b) -> weight, meaning "a depends on b". Returns (order, back),
785 where order[0] is the foundation and `back` lists the edges still pointing the
786 wrong way: the minimum set of dependencies that would have to go for a layering
787 to exist at all - a minimum feedback arc set.
788
789 Nothing is declared. Topologically sorting instead would measure nothing (a
790 topological order has no backward edges by construction) and does not exist
791 anyway once the graph has a cycle, which is the interesting case.
792
793 Eades-Lin-Smyth greedy: strip sinks to the back and sources to the front, and
794 when neither exists - exactly when a cycle is in the way - remove the node with
795 the largest outgoing-minus-incoming weight. Linear time, and at most
796 |E|/2 - |V|/6 backward edges. Implemented with worklists rather than rescans so
797 it stays linear on the file graph, which is two orders of magnitude larger than
798 the directory graph.
799 """
800 if not edges:
801 return [], []
802
803 nodes = set()
804 succ, pred = defaultdict(list), defaultdict(list)
805 out_w, in_w = Counter(), Counter()
806 for (a, b), n in edges.items():
807 nodes.add(a)
808 nodes.add(b)
809 succ[a].append((b, n))
810 pred[b].append((a, n))
811 out_w[a] += n
812 in_w[b] += n
813 for n in nodes:
814 out_w.setdefault(n, 0)
815 in_w.setdefault(n, 0)
816
817 remaining = set(nodes)
818 sinks = [u for u in nodes if out_w[u] == 0]
819 sources = [u for u in nodes if in_w[u] == 0 and out_w[u] != 0]
820 head, tail = [], []
821
822 def drop(u):
824 for v, n in succ[u]:
825 if v in remaining:
826 in_w[v] -= n
827 if in_w[v] == 0 and out_w[v] != 0:
829 for v, n in pred[u]:
830 if v in remaining:
831 out_w[v] -= n
832 if out_w[v] == 0:
833 sinks.append(v)
834
835 while remaining:
836 progressed = True
837 while progressed:
838 progressed = False
839 while sinks:
840 u = sinks.pop()
841 if u in remaining:
842 tail.append(u)
843 drop(u)
844 progressed = True
845 while sources:
846 u = sources.pop()
847 if u in remaining:
848 head.append(u)
849 drop(u)
850 progressed = True
851 if remaining:
852 u = max(remaining, key=lambda m: (out_w[m] - in_w[m], m))
853 head.append(u)
854 drop(u)
855
856 # head holds the biggest dependers first; reverse the whole sequence so that
857 # rank 0 reads as the foundation, the way a layer stack is normally drawn.
858 order = (head + tail[::-1])[::-1]
859 pos = {m: i for i, m in enumerate(order)}
860 back = []
861 for (a, b), n in edges.items():
862 if pos[a] < pos[b]: # lower in the derived stack reaching up
863 back.append({"pair": "%s -> %s" % (a, b), "includes": n,
864 "from_rank": pos[a], "to_rank": pos[b]})
865 back.sort(key=lambda v: (-v["includes"], v["pair"]))
866 return order, back
867
868
869def derive_layering(edges, label):
870 """Summarise a feedback-arc-set ordering of one dependency graph."""
871 if not edges:
872 return None
873 order, back = feedback_arc_order(edges)
874 total = sum(edges.values())
875 weighted = sum(v["includes"] for v in back)
876 return {
877 "unit": label,
878 "nodes": len(order),
879 "edges": len(edges),
880 "includes": total,
881 "order": [{"rank": i, "name": m} for i, m in enumerate(order)],
882 "back_edges": len(back),
883 "back_includes": weighted,
884 "back_ratio": round(100.0 * weighted / max(1, total), 1),
885 "worst": back[:25],
886 }
887
888
889def compute_stability(mod_edges):
890 """Robert Martin's instability metric, and the violations it implies.
891
892 A second graph-derived view of the same question, independent of the ordering
893 above. Two independent derivations agreeing is worth more than either alone.
894
895 It is computed over directories, so it inherits their weakness as a unit - they
896 are drawers, not modules - and is reported for what it is:
897
898 Ca (afferent) how many modules depend on this one
899 Ce (efferent) how many modules this one depends on
900 I = Ce / (Ca + Ce) instability, 0 .. 1
901
902 I = 0 is a module everyone depends on and that depends on nothing: maximally
903 stable, expensive to change, and it had better be a leaf library. I = 1 is a
904 module nobody depends on: free to change, and it had better be a leaf consumer.
905
906 The Stable Dependencies Principle says a module should only depend on modules
907 at least as stable as itself. An edge A -> B with I(A) < I(B) breaks it: the
908 harder-to-change module was made to depend on the easier-to-change one, so the
909 volatile module's churn propagates into the stable one. That is the same defect
910 "layer inversion" is looking for, established without anyone declaring a layer.
911
912 Note the two can legitimately disagree, and where they do is interesting rather
913 than wrong: a widely used module that itself reaches into a volatile one scores
914 badly here even if the declared layers approve of it.
915 """
916 if not mod_edges:
917 return None
918 afferent, efferent = defaultdict(set), defaultdict(set)
919 for (a, b) in mod_edges:
920 efferent[a].add(b)
921 afferent[b].add(a)
922
923 modules = sorted(set(afferent) | set(efferent))
924 inst = {}
925 for m in modules:
926 ca, ce = len(afferent[m]), len(efferent[m])
927 inst[m] = (ce / float(ca + ce)) if (ca + ce) else 0.0
928
929 violations, weighted, ranked = [], 0, 0
930 for (a, b), n in mod_edges.items():
931 ranked += n
932 if inst[a] < inst[b] - 1e-9: # stable depending on less stable
933 violations.append({"pair": "%s -> %s" % (a, b), "includes": n,
934 "from_I": round(inst[a], 2), "to_I": round(inst[b], 2)})
935 weighted += n
936 violations.sort(key=lambda v: -v["includes"])
937
938 table = [{"module": m, "Ca": len(afferent[m]), "Ce": len(efferent[m]),
939 "I": round(inst[m], 2)} for m in modules]
940 table.sort(key=lambda r: (r["I"], -r["Ca"]))
941 return {
942 "modules": table,
943 "violating_edges": len(violations),
944 "violating_includes": weighted,
945 "violation_ratio": round(100.0 * weighted / max(1, ranked), 1),
946 "worst": violations[:20],
947 }
948
949
950def collect_layering(edges, source_dir="src"):
951 """Dependency cycles and derived layering, at file and at directory level.
952
953 Everything here comes from the include graph. Nothing is declared.
954
955 Cycles are the objective part: if A depends on B and B on A, no layering of the
956 two exists, whatever anyone believes. Reported as strongly connected components.
957
958 The derived ordering is the graduated part: order the units so as few includes as
959 possible point backwards, and report what still does. Those edges are the minimum
960 set of dependencies that would have to go for a layering to exist at all.
961
962 The FILE graph is the primary unit, because it presumes nothing about how the
963 tree is organised - it does not need directories to be modules, which in
964 darktable they are not. The directory graph is reported too, as an aggregation
965 over what are really drawers rather than modules.
966 """
967 if not edges:
968 return None
969
970 file_edges = Counter()
971 mod_edges = Counter()
972 file_succ = defaultdict(set)
973 files = set()
974 for a, b in edges:
975 files.add(a)
976 files.add(b)
977 file_succ[a].add(b)
978 file_edges[(a, b)] += 1
979 ma, mb = module_of(a, source_dir), module_of(b, source_dir)
980 if ma and mb and ma != mb:
981 mod_edges[(ma, mb)] += 1
982
983 # ---- cycles between individual files
984 file_cycles = [c for c in strongly_connected(sorted(files), file_succ) if len(c) > 1]
985 file_cycles.sort(key=len, reverse=True)
986 # A strongly connected component of N headers is NOT "one cycle": it is a tangle
987 # that generally contains many distinct ones, and reporting a bare count of
988 # components makes a seven-header knot look exactly like a two-header pair. So
989 # every component is reported with its size and with the number of includes that
990 # would have to be cut to break it - the feedback arcs inside that component.
991 cycle_detail = []
992 for comp in file_cycles:
993 members = set(comp)
994 inner = {(a, b): 1 for a in members for b in file_succ.get(a, ()) if b in members}
995 _o, back = feedback_arc_order(inner)
997 "size": len(comp),
998 "internal_edges": len(inner),
999 "cuts_to_break": len(back),
1000 "files": sorted(comp),
1001 "cut_edges": [v["pair"] for v in back],
1002 })
1003
1004 # ---- cycles between directories
1005 mod_succ = defaultdict(set)
1006 for (ma, mb) in mod_edges:
1007 mod_succ[ma].add(mb)
1008 mod_cycles = [sorted(c) for c in strongly_connected(sorted(mod_succ), mod_succ)
1009 if len(c) > 1]
1010 mod_cycles.sort(key=len, reverse=True)
1011
1012 return {
1013 "by_file": derive_layering(file_edges, "file"),
1014 "by_directory": derive_layering(mod_edges, "directory"),
1015 "stability": compute_stability(mod_edges),
1016 "module_edges": len(mod_edges),
1017 "module_include_count": sum(mod_edges.values()),
1018 "module_cycles": mod_cycles[:15],
1019 "module_cycle_count": len(mod_cycles),
1020 "modules_in_cycles": sum(len(c) for c in mod_cycles),
1021 "file_cycle_count": len(file_cycles),
1022 "file_cycles": cycle_detail,
1023 "largest_file_cycle_size": max((len(c) for c in file_cycles), default=0),
1024 "file_cycle_cuts": sum(c["cuts_to_break"] for c in cycle_detail),
1025 "files_in_cycles": sum(len(c) for c in file_cycles),
1026 "largest_file_cycle": sorted(file_cycles[0]) if file_cycles else [],
1027 }
1028
1029
1031 """Who includes the application-global header.
1032
1033 darktable's src/common/darktable.h and Ansel's src/darktable.h are the same file
1034 by descent. The number that matters is how many HEADERS include it: a .c doing so
1035 is a choice local to that file, a .h doing so pushes the whole application into
1036 every file downstream of it.
1037 """
1038 if not edges:
1039 return None
1040 target = None
1041 for _a, b in edges:
1042 if b.replace(os.sep, "/").endswith("/darktable.h") or b == "darktable.h":
1043 target = b
1044 break
1045 if not target:
1046 return None
1047 headers = [a for a, b in edges if b == target and a.endswith((".h", ".hpp"))]
1048 sources = [a for a, b in edges if b == target and not a.endswith((".h", ".hpp"))]
1049 return {
1050 "header": target,
1051 "included_by_headers": len(headers),
1052 "included_by_sources": len(sources),
1053 "total": len(headers) + len(sources),
1054 "headers": sorted(headers)[:40],
1055 }
1056
1057
1058# --------------------------------------------------------------------------- lizard
1059
1060
1061def collect_ccn(source_dir):
1062 """Per-function cyclomatic complexity, via lizard.
1063
1064 The distribution matters more than the total: a codebase's maintenance cost lives
1065 in its tail, not its mean, so the thresholds below are reported as counts.
1066 """
1067 if not shutil.which("lizard"):
1068 return None
1069 cmd = ["lizard", "--csv", "-l", "c", "-l", "cpp"]
1070 for glob in excluded_globs():
1071 cmd += ["-x", glob]
1072 cmd.append(source_dir)
1073 ok, out = run(cmd)
1074 if not out.strip():
1075 sys.stderr.write("code_health: lizard produced no output\n")
1076 return None
1077
1078 funcs = []
1079 # Parsed with the csv module, NOT by splitting on commas: lizard quotes the
1080 # location, file, name and long_name fields, and long_name holds the parameter
1081 # list, which is full of commas. A naive split also leaves the surrounding
1082 # quotation marks on the path, so "src/foo.c" no longer ends in .c and every
1083 # function silently fails the production-file allowlist - which is exactly how
1084 # this whole section once vanished from the panel without any error.
1085 for parts in csv.reader(out.splitlines()):
1086 # nloc,ccn,token,param,length,location,file,name,long_name,start,end
1087 if len(parts) < 8:
1088 continue
1089 try:
1090 nloc, ccn, _tok, params, length = (int(parts[i]) for i in range(5))
1091 except ValueError:
1092 continue # header row
1093 path, name = parts[6], parts[7]
1094 if is_excluded(path):
1095 continue
1097 {"file": path, "name": name, "ccn": ccn, "nloc": nloc,
1098 "params": params, "length": length}
1099 )
1100 if not funcs:
1101 return None
1102
1103 ccns = sorted(f["ccn"] for f in funcs)
1104 nlocs = [f["nloc"] for f in funcs]
1105
1106 def pct(p):
1107 if not ccns:
1108 return 0
1109 idx = min(len(ccns) - 1, max(0, int(round((p / 100.0) * (len(ccns) - 1)))))
1110 return ccns[idx]
1111
1112 per_file = Counter()
1113 for f in funcs:
1114 per_file[f["file"]] += f["ccn"]
1115 worst = sorted(funcs, key=lambda f: (-f["ccn"], -f["nloc"]))[:40]
1116 longest = sorted(funcs, key=lambda f: -f["nloc"])[:20]
1117 return {
1118 "functions": len(funcs),
1119 "ccn_total": sum(ccns),
1120 "ccn_mean": round(sum(ccns) / float(len(ccns)), 2),
1121 "ccn_median": pct(50),
1122 "ccn_p90": pct(90),
1123 "ccn_p99": pct(99),
1124 "ccn_max": ccns[-1],
1125 "nloc_total": sum(nlocs),
1126 "nloc_mean": round(sum(nlocs) / float(len(nlocs)), 1),
1127 "over_15": sum(1 for c in ccns if c > 15),
1128 "over_25": sum(1 for c in ccns if c > 25),
1129 "over_50": sum(1 for c in ccns if c > 50),
1130 "over_100": sum(1 for c in ccns if c > 100),
1131 "long_over_100_lines": sum(1 for n in nlocs if n > 100),
1132 "long_over_300_lines": sum(1 for n in nlocs if n > 300),
1133 "params_over_7": sum(1 for f in funcs if f["params"] > 7),
1134 "worst": worst,
1135 "longest": longest,
1136 "per_file_ccn": dict(per_file),
1137 }
1138
1139
1140# --------------------------------------------------------------------------- cppcheck
1141
1142
1143def collect_cppcheck(source_dir, jobs):
1144 """cppcheck findings by severity and by rule id.
1145
1146 cppcheck is used rather than clang-tidy for the always-on panel because it needs
1147 no compile_commands.json, so it runs in the docs job on both repositories under
1148 identical conditions. clang-tidy findings, which need a configured build tree,
1149 are folded in from --clang-tidy-log when a job that can produce one has run.
1150 """
1151 if not shutil.which("cppcheck"):
1152 return None
1153 cmd = [
1154 "cppcheck", "--quiet", "--enable=all", "--inline-suppr",
1155 "--suppress=missingInclude", "--suppress=missingIncludeSystem",
1156 "--suppress=unmatchedSuppression", "--suppress=checkersReport",
1157 "--template={severity}|{id}|{file}",
1158 "-j", str(jobs),
1159 source_dir,
1160 ]
1161 # cppcheck filters by path prefix. Feed it every excluded directory - submodules
1162 # included - that actually exists, so no vendored translation unit is analysed.
1163 for part in EXCLUDED_DIR_PARTS:
1164 rel = part.strip("/")
1165 for candidate in (rel, os.path.join(source_dir, os.path.basename(rel))):
1166 if os.path.isdir(candidate):
1167 cmd[-1:-1] = ["-i", candidate]
1168 try:
1169 r = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
1170 except (OSError, subprocess.SubprocessError) as exc:
1171 sys.stderr.write("code_health: cppcheck failed: %s\n" % exc)
1172 return None
1173
1174 by_sev, by_id = Counter(), Counter()
1175 for line in r.stderr.splitlines(): # cppcheck reports on stderr
1176 parts = line.split("|")
1177 if len(parts) < 3:
1178 continue
1179 sev, rule, path = parts[0], parts[1], parts[2]
1180 if is_excluded(path):
1181 continue
1182 by_sev[sev] += 1
1183 by_id[rule] += 1
1184 if not by_sev:
1185 return None
1186 return {
1187 "total": sum(by_sev.values()),
1188 "by_severity": dict(by_sev.most_common()),
1189 "top_rules": by_id.most_common(25),
1190 }
1191
1192
1193# --------------------------------------------------------------------------- clang-tidy
1194
1195
1196CLANG_TIDY_LINE = re.compile(
1197 r"^(?P<file>[^:\s]+):\d+:\d+:\s+(?P<sev>warning|error):"
1198 r"\s+.*\[(?P<check>[a-zA-Z0-9_.\-,]+)\]\s*$"
1199)
1200
1201
1203 """Aggregate a clang-tidy run's console log by check name.
1204
1205 Deliberately parses the log rather than running clang-tidy: producing
1206 compile_commands.json needs a configured build tree and the project's full
1207 dependency set, which does not belong in the docs job.
1208 """
1209 if not log_path or not os.path.exists(log_path):
1210 return None
1211 by_check, by_sev, files = Counter(), Counter(), set()
1212 seen = set()
1213 with open(log_path, encoding="utf-8", errors="replace") as fh:
1214 for line in fh:
1216 if not m:
1217 continue
1218 path = m.group("file")
1219 if is_excluded(path):
1220 continue
1221 # clang-tidy repeats a finding once per translation unit that includes
1222 # the header it lives in; dedupe on the whole location+check.
1223 key = line.strip()
1224 if key in seen:
1225 continue
1226 checks = m.group("check").split(",")
1227 # clang-tidy reports the flags a GCC-oriented build passes that clang does
1228 # not know as findings. They are about build flags, not about the code, and
1229 # would otherwise dominate the tally.
1230 if checks[0] == "clang-diagnostic-unknown-warning-option":
1231 continue
1232 seen.add(key)
1233 # A finding tagged [bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp]
1234 # is ONE finding reported under a check and its aliases. Count the primary
1235 # name only, or every aliased check inflates the table three-fold.
1236 by_check[checks[0]] += 1
1237 by_sev[m.group("sev")] += 1
1238 files.add(path)
1239 if not by_check:
1240 return None
1241 return {
1242 "total": sum(by_sev.values()),
1243 "files_with_findings": len(files),
1244 "by_severity": dict(by_sev.most_common()),
1245 "top_checks": by_check.most_common(25),
1246 }
1247
1248
1249# --------------------------------------------------------------------------- cloc
1250
1251
1252def collect_cloc(source_dir):
1253 """Lines of code, counted per file and filtered with this module's own predicate.
1254
1255 cloc's --not-match-d is NOT used to drop vendored code: depending on the cloc
1256 version it matches a single path component rather than a subtree, so
1257 src/external/rawspeed/src/... survives a --not-match-d on "external". That went
1258 unnoticed locally and inflated the published figures to 1,012,392 lines against
1259 the real 331,243. Counting --by-file and filtering through is_excluded() is the
1260 only way this section agrees with every other section of the panel.
1261 """
1262 if not shutil.which("cloc"):
1263 return None
1264 ok, out = run(["cloc", "--quiet", "--json", "--by-file", source_dir])
1265 if not ok or not out.strip():
1266 return None
1267 try:
1268 data = json.loads(out)
1269 except ValueError:
1270 return None
1271 data.pop("header", None)
1272 data.pop("SUM", None)
1273
1274 totals = Counter()
1275 per_lang = defaultdict(Counter)
1276 for path, v in data.items():
1277 lang = v.get("language", "unknown")
1278 # Two independent gates. is_excluded() drops vendored and non-shipping paths;
1279 # the language allowlist additionally catches files cloc classifies by content
1280 # rather than by extension - a suffixless helper with a python shebang, for
1281 # instance - which no path rule can see.
1282 if lang not in PRODUCTION_LANGUAGES or is_excluded(path):
1283 continue
1284 for key in ("blank", "comment", "code"):
1285 totals[key] += v.get(key, 0)
1286 per_lang[lang][key] += v.get(key, 0)
1287 totals["nFiles"] += 1
1288 per_lang[lang]["nFiles"] += 1
1289 if not totals:
1290 return None
1291
1292 langs = sorted(
1293 ({"language": k, **dict(v)} for k, v in per_lang.items()),
1294 key=lambda d: -d.get("code", 0),
1295 )[:12]
1296 return {"sum": dict(totals), "languages": langs}
1297
1298
1299# --------------------------------------------------------------------------- report
1300
1301
1302def md_table(headers, rows, aligns=None):
1303 if not rows:
1304 return "_no data_\n"
1305 aligns = aligns or ["---"] * len(headers)
1306 out = ["| " + " | ".join(headers) + " |", "| " + " | ".join(aligns) + " |"]
1307 for r in rows:
1308 out.append("| " + " | ".join(str(c) for c in r) + " |")
1309 return "\n".join(out) + "\n"
1310
1311
1312def build_markdown(project, data):
1313 L = []
1314 A = L.append
1315 A("Code health {#code_health}")
1316 A("============")
1317 A("")
1318 A("Generated with `tools/code_health.py` during the documentation build. The same")
1319 A("script, with the same thresholds and the same third-party exclusions, runs in the")
1320 A("Ansel repository and in the frozen darktable 5.0 reference tree, so the two")
1321 A("published panels can be read side by side.")
1322 A("")
1323 A("Vendored code is excluded throughout (`src/external/`, the integration-test data,")
1324 A("the Doxygen theme), matching `sonar.exclusions` in `.sonarcloud.properties`. Every")
1325 A("number below therefore describes code this repository actually authors.")
1326 A("")
1327
1328 # ---- size
1329 cloc = data.get("cloc")
1330 A("[TOC]")
1331 A("")
1332 A("Size {#ch_size}")
1333 A("----")
1334 A("")
1335 if cloc and cloc.get("sum"):
1336 s = cloc["sum"]
1337 A(md_table(
1338 ["Measure", "Value"],
1339 [["Files", "{:,}".format(int(s.get("nFiles", 0)))],
1340 ["Lines of code", "{:,}".format(int(s.get("code", 0)))],
1341 ["Comment lines", "{:,}".format(int(s.get("comment", 0)))],
1342 ["Blank lines", "{:,}".format(int(s.get("blank", 0)))],
1343 ["Comment ratio", "{:.1f} %".format(
1344 100.0 * s.get("comment", 0) / max(1, s.get("code", 0) + s.get("comment", 0)))]],
1345 ["---", "--:"]))
1346 A("")
1347 A(md_table(
1348 ["Language", "Files", "Code", "Comment"],
1349 [[l["language"], "{:,}".format(l.get("nFiles", 0)),
1350 "{:,}".format(l.get("code", 0)), "{:,}".format(l.get("comment", 0))]
1351 for l in cloc["languages"]],
1352 ["---", "--:", "--:", "--:"]))
1353 else:
1354 A("_cloc not available._")
1355 A("")
1356
1357 # ---- complexity
1358 ccn = data.get("ccn")
1359 A("Cyclomatic complexity {#ch_ccn}")
1360 A("---------------------")
1361 A("")
1362 if ccn:
1363 A("Per-function CCN, measured by `lizard`. The mean is the least interesting number")
1364 A("here: maintenance cost lives in the tail, so the counts above each threshold are")
1365 A("what to compare.")
1366 A("")
1367 A(md_table(
1368 ["Measure", "Value"],
1369 [["Functions", "{:,}".format(ccn["functions"])],
1370 ["Total CCN", "{:,}".format(ccn["ccn_total"])],
1371 ["Mean CCN", ccn["ccn_mean"]],
1372 ["Median CCN", ccn["ccn_median"]],
1373 ["90th percentile", ccn["ccn_p90"]],
1374 ["99th percentile", ccn["ccn_p99"]],
1375 ["Maximum CCN", ccn["ccn_max"]],
1376 ["Mean function length (NLOC)", ccn["nloc_mean"]]],
1377 ["---", "--:"]))
1378 A("")
1379 total = float(max(1, ccn["functions"]))
1380 A(md_table(
1381 ["Threshold", "Functions", "Share"],
1382 [["CCN > 15 (worth refactoring)", "{:,}".format(ccn["over_15"]),
1383 "{:.1f} %".format(100 * ccn["over_15"] / total)],
1384 ["CCN > 25 (hard to test)", "{:,}".format(ccn["over_25"]),
1385 "{:.1f} %".format(100 * ccn["over_25"] / total)],
1386 ["CCN > 50", "{:,}".format(ccn["over_50"]),
1387 "{:.1f} %".format(100 * ccn["over_50"] / total)],
1388 ["CCN > 100", "{:,}".format(ccn["over_100"]),
1389 "{:.1f} %".format(100 * ccn["over_100"] / total)],
1390 ["Longer than 100 lines", "{:,}".format(ccn["long_over_100_lines"]),
1391 "{:.1f} %".format(100 * ccn["long_over_100_lines"] / total)],
1392 ["Longer than 300 lines", "{:,}".format(ccn["long_over_300_lines"]),
1393 "{:.1f} %".format(100 * ccn["long_over_300_lines"] / total)],
1394 ["More than 7 parameters", "{:,}".format(ccn["params_over_7"]),
1395 "{:.1f} %".format(100 * ccn["params_over_7"] / total)]],
1396 ["---", "--:", "--:"]))
1397 A("")
1398 A("### Most complex functions {#ch_ccn_worst}")
1399 A("")
1400 A(md_table(
1401 ["CCN", "NLOC", "Params", "Function", "File"],
1402 [[f["ccn"], f["nloc"], f["params"], "`%s`" % f["name"], f["file"]]
1403 for f in ccn["worst"]],
1404 ["--:", "--:", "--:", "---", "---"]))
1405 A("")
1406 A("### Longest functions {#ch_ccn_longest}")
1407 A("")
1408 A(md_table(
1409 ["NLOC", "CCN", "Function", "File"],
1410 [[f["nloc"], f["ccn"], "`%s`" % f["name"], f["file"]]
1411 for f in ccn["longest"]],
1412 ["--:", "--:", "---", "---"]))
1413 else:
1414 A("_lizard not available._")
1415 A("")
1416
1417 # ---- layering
1418 lay = data.get("layering")
1419 god = data.get("god_header")
1420 A("Layering {#ch_layering}")
1421 A("--------")
1422 A("")
1423 if lay:
1424 A("Everything in this section is derived from the include graph. Nothing is")
1425 A("declared by hand, and there is no table of \"which layer should sit above")
1426 A("which\" - an earlier version of this panel had one, and it was wrong twice: it")
1427 A("encoded one reading of the architecture, and it assumed `src/` subdirectories")
1428 A("are modules. In darktable they are not. They are drawers - groupings of")
1429 A("convenience with no ownership or interface boundary - so \"common sits below")
1430 A("control\" is an assertion the code never made.")
1431 A("")
1432 A("The primary unit here is therefore the **file**, which presumes nothing about")
1433 A("how the tree is organised. Directory-level figures follow, labelled as the")
1434 A("aggregation over drawers that they are.")
1435 A("")
1436 A("Two questions, kept apart:")
1437 A("")
1438 A("- **Cycles are absolute.** If A depends on B and B on A, no layering of the two")
1439 A(" exists, whatever anyone believes. Counted as strongly connected components.")
1440 A("- **Backward dependencies are graduated.** Order the units so as few includes as")
1441 A(" possible point backwards; what still does is the minimum set that would have")
1442 A(" to go for a layering to exist at all. Computed with the Eades-Lin-Smyth")
1443 A(" feedback-arc-set heuristic, weighted by include count.")
1444 A("")
1445 A(md_table(
1446 ["Measure", "Files", "Directories"],
1447 [["Units", "{:,}".format((lay.get("by_file") or {}).get("nodes", 0)),
1448 "{:,}".format((lay.get("by_directory") or {}).get("nodes", 0))],
1449 ["Dependency edges", "{:,}".format((lay.get("by_file") or {}).get("edges", 0)),
1450 "{:,}".format((lay.get("by_directory") or {}).get("edges", 0))],
1451 ["Cycles", "{:,}".format(lay["file_cycle_count"]),
1452 "{:,}".format(lay["module_cycle_count"])],
1453 ["Units caught in a cycle", "{:,}".format(lay["files_in_cycles"]),
1454 "{:,}".format(lay["modules_in_cycles"])],
1455 ["Backward dependencies", "{:,}".format((lay.get("by_file") or {}).get("back_edges", 0)),
1456 "{:,}".format((lay.get("by_directory") or {}).get("back_edges", 0))],
1457 ["Includes on them", "{:,}".format((lay.get("by_file") or {}).get("back_includes", 0)),
1458 "{:,}".format((lay.get("by_directory") or {}).get("back_includes", 0))],
1459 ["Share of all includes", "{} %".format((lay.get("by_file") or {}).get("back_ratio", 0)),
1460 "{} %".format((lay.get("by_directory") or {}).get("back_ratio", 0))]],
1461 ["---", "--:", "--:"]))
1462 A("")
1463 bf = lay.get("by_file")
1464 if bf and bf["worst"]:
1465 A("### Backward dependencies between files {#ch_layering_files}")
1466 A("")
1467 A("Each of these is an include that could not be ordered away. Removing this set")
1468 A("would leave a tree that can be laid out in strict layers.")
1469 A("")
1470 A(md_table(
1471 ["Include", "From rank", "To rank"],
1472 [["`%s`" % v["pair"], v["from_rank"], v["to_rank"]] for v in bf["worst"]],
1473 ["---", "--:", "--:"]))
1474 A("")
1475 bd = lay.get("by_directory")
1476 if bd and bd["worst"]:
1477 A("### Backward dependencies between directories {#ch_layering_dirs}")
1478 A("")
1479 A("Aggregated over `src/` subdirectories. Read with the caveat above: these are")
1480 A("drawers, so a large number here says the include flow between two drawers is")
1481 A("two-way, not that a designed boundary was broken.")
1482 A("")
1483 A("Derived order, rank 0 first:")
1484 A("")
1485 A("> " + " &lt; ".join("`%s`" % m["name"] for m in bd["order"]))
1486 A("")
1487 A(md_table(
1488 ["Includes", "Points backwards", "From rank", "To rank"],
1489 [[v["includes"], "`%s`" % v["pair"], v["from_rank"], v["to_rank"]]
1490 for v in bd["worst"]],
1491 ["--:", "---", "--:", "--:"]))
1492 A("")
1493 if lay["module_cycles"]:
1494 A("### Directory dependency cycles {#ch_layering_cycles}")
1495 A("")
1496 A(md_table(["Directories", "Cycle"],
1497 [[len(c), ", ".join("`%s`" % m for m in c)]
1498 for c in lay["module_cycles"]],
1499 ["--:", "---"]))
1500 A("")
1501 if lay.get("file_cycles"):
1502 A("### Cyclic header clusters {#ch_layering_filecycle}")
1503 A("")
1504 A("Each block below is a strongly connected component: a set of files that all")
1505 A("reach one another through includes. **A component is not one cycle.** A")
1506 A("seven-header component contains many distinct cycles, which is why the count")
1507 A("of components is a poor headline and the size, and the number of includes that")
1508 A("must be cut to break it, are given instead.")
1509 A("")
1510 A(md_table(
1511 ["Files", "Internal includes", "Cuts to break"],
1512 [[c["size"], c["internal_edges"], c["cuts_to_break"]]
1513 for c in lay["file_cycles"]],
1514 ["--:", "--:", "--:"]))
1515 A("")
1516 for i, c in enumerate(lay["file_cycles"], 1):
1517 A("**Cluster %d** - %d files, %d cuts to break:"
1518 % (i, c["size"], c["cuts_to_break"]))
1519 A("")
1520 for f in c["files"]:
1521 A("- `%s`" % f)
1522 A("")
1523 if c["cut_edges"]:
1524 A("Cutting these breaks it:")
1525 A("")
1526 for e in c["cut_edges"]:
1527 A("- `%s`" % e)
1528 A("")
1529 st = lay.get("stability")
1530 if st:
1531 A("### Stability {#ch_layering_stability}")
1532 A("")
1533 A("A second graph-derived view, independent of the ordering above; two")
1534 A("derivations agreeing is worth more than either alone. `Ca` counts the")
1535 A("directories depending on one, `Ce` those it depends on, and instability is")
1536 A("`I = Ce / (Ca + Ce)`. `I = 0` means everything depends on it and it depends")
1537 A("on nothing - expensive to change. `I = 1` means nothing depends on it.")
1538 A("")
1539 A("The Stable Dependencies Principle says a unit should depend only on units at")
1540 A("least as stable as itself; an edge with `I(from) < I(to)` breaks it, letting a")
1541 A("volatile unit's churn propagate into a stable one. Computed over directories,")
1542 A("so it inherits their weakness as a unit.")
1543 A("")
1544 A(md_table(
1545 ["Measure", "Value"],
1546 [["Edges breaking the principle", "{:,}".format(st["violating_edges"])],
1547 ["Includes on those edges", "{:,}".format(st["violating_includes"])],
1548 ["Share of cross-directory includes", "{} %".format(st["violation_ratio"])]],
1549 ["---", "--:"]))
1550 A("")
1551 A(md_table(
1552 ["Directory", "Ca", "Ce", "I"],
1553 [["`%s`" % r["module"], r["Ca"], r["Ce"], r["I"]] for r in st["modules"]],
1554 ["---", "--:", "--:", "--:"]))
1555 A("")
1556 else:
1557 A("_include data not available (needs Doxygen's SQLite output)._")
1558 A("")
1559 if god:
1560 A("### The application-global header {#ch_layering_god}")
1561 A("")
1562 A("`%s` is the header every fork of this codebase inherits. A `.c` including it"
1563 % god["header"])
1564 A("is a choice local to that file; a **header** including it pushes the whole")
1565 A("application into every file downstream, which is how an include graph stops")
1566 A("being a graph and becomes a mesh.")
1567 A("")
1568 A(md_table(
1569 ["Included by", "Count"],
1570 [["Headers", "{:,}".format(god["included_by_headers"])],
1571 ["Source files", "{:,}".format(god["included_by_sources"])],
1572 ["Total", "{:,}".format(god["total"])]],
1573 ["---", "--:"]))
1574 A("")
1575 if god["headers"]:
1576 A("Headers that include it:")
1577 A("")
1578 for h in god["headers"]:
1579 A("- `%s`" % h)
1580 A("")
1581
1582 # ---- transitive reach
1583 rch = data.get("reach")
1584 if rch:
1585 A("Transitive reach {#ch_reach}")
1586 A("----------------")
1587 A("")
1588 A("Direct fan-in undercounts, and cycle counts miss this entirely. A header that")
1589 A("only 40 files include, but which those 40 pass on, can still end up under most")
1590 A("of the codebase. What follows is the transitive answer: how much of the tree")
1591 A("depends on each header, and how much each translation unit drags in.")
1592 A("")
1593 A("### Propagation cost {#ch_reach_prop}")
1594 A("")
1595 A("Propagation cost is the mean share of the system a file can reach: the")
1596 A("probability that a change to a random file can, in principle, reach a random")
1597 A("other one. It is the standard summary of architectural coupling")
1598 A("(MacCormack, Baldwin & Rusnak), and the single number this section is circling.")
1599 A("")
1600 A("The **core** is the largest group of files that all reach one another. Being a")
1601 A("cyclic group it has no internal layering by definition, so it can only be")
1602 A("understood as a unit. Everything else is classified against the core's")
1603 A("thresholds. A healthy system has a small core and a large periphery.")
1604 A("")
1605 A(md_table(
1606 ["Measure", "Value"],
1607 [["Propagation cost", "{} %".format(rch["propagation_cost"])],
1608 ["Core size", "{:,} files ({} %)".format(rch["core_size"], rch["core_share"])]]
1609 + [[k.capitalize(), "{:,}".format(v)]
1610 for k, v in sorted(rch.get("buckets", {}).items(), key=lambda kv: -kv[1])],
1611 ["---", "--:"]))
1612 A("")
1613 if rch.get("core_files"):
1614 A("Files in the core:")
1615 A("")
1616 for f in rch["core_files"]:
1617 A("- `%s`" % f)
1618 A("")
1619 A(md_table(
1620 ["Measure", "Value"],
1621 [["Files in the graph", "{:,}".format(rch["files"])],
1622 ["Headers reaching over half the tree", "{:,}".format(rch["headers_over_half"])],
1623 ["Headers reaching over a quarter", "{:,}".format(rch["headers_over_quarter"])],
1624 ["Headers pulled in per source file, median", "{:,}".format(rch["median_headers_pulled"])],
1625 ["Headers pulled in per source file, mean", rch["mean_headers_pulled"]],
1626 ["Worst", "{:,}".format(rch["max_headers_pulled"])]],
1627 ["---", "--:"]))
1628 A("")
1629 A("### Headers most of the codebase depends on {#ch_reach_dependents}")
1630 A("")
1631 A("Changing one of these means rebuilding, and re-reviewing, that share of the")
1632 A("tree. This is the cost a god header imposes, and it is invisible to every")
1633 A("cycle metric: a header can sit in no cycle at all and still be here.")
1634 A("")
1635 A(md_table(
1636 ["Dependents", "Share of tree", "Drags in", "Header"],
1637 [["{:,}".format(r["dependents"]), "{} %".format(r["share"]),
1638 "{:,}".format(r["drags_in"]), "`%s`" % r["file"]]
1639 for r in rch["top_dependents"]],
1640 ["--:", "--:", "--:", "---"]))
1641 A("")
1642 A("### Heaviest supply lines {#ch_reach_burden}")
1643 A("")
1644 A("A header should include only what its own declarations need. Anything beyond")
1645 A("that is a supply line its consumers never asked for and cannot see: they compile")
1646 A("because something upstream happened to pull in what they use, and the day anyone")
1647 A("tidies that away the breakage surfaces in a file nobody touched.")
1648 A("")
1649 A("`Drags in` is how many headers arrive with this one. Multiplied by its")
1650 A("dependents, it gives the file-header pairs this single header is responsible for")
1651 A("across the tree - the weight it actually imposes, rather than how popular it is.")
1652 A("")
1653 A(md_table(
1654 ["Burden", "Dependents", "Drags in", "Header"],
1655 [["{:,}".format(r["burden"]), "{:,}".format(r["dependents"]),
1656 "{:,}".format(r["drags_in"]), "`%s`" % r["file"]]
1657 for r in rch["top_burden"]],
1658 ["--:", "--:", "--:", "---"]))
1659 A("")
1660 A("### Translation units pulling in the most headers {#ch_reach_depth}")
1661 A("")
1662 A(md_table(
1663 ["Headers pulled", "File"],
1664 [["{:,}".format(r["headers_pulled"]), "`%s`" % r["file"]] for r in rch["deepest"]],
1665 ["--:", "---"]))
1666 A("")
1667
1668 # ---- coupling
1669 inc = data.get("includers")
1670 A("Header coupling {#ch_coupling}")
1671 A("---------------")
1672 A("")
1673 if inc:
1674 A("Direct fan-in: how many files include each header. This is the number behind the")
1675 A('"included by" graphs on each file page, and the clearest single measure of how')
1676 A("entangled the headers are. A header near the top of this table cannot be changed")
1677 A("without rebuilding, and re-reviewing, most of the codebase.")
1678 A("")
1679 A(md_table(
1680 ["Included by", "Header"],
1681 [[r["included_by"], r["file"]] for r in inc[:40]],
1682 ["--:", "---"]))
1683 else:
1684 A("_include data not available (needs Doxygen's SQLite output)._")
1685 A("")
1686
1687 # ---- symbols
1688 sym = data.get("symbols")
1689 A("Symbols per file {#ch_symbols}")
1690 A("----------------")
1691 A("")
1692 if sym:
1693 tot = sum(r["total"] for r in sym)
1694 A("From Doxygen's own symbol table. A file with a very large symbol count is doing")
1695 A("more than one job; a header with one is an interface.")
1696 A("")
1697 A(md_table(
1698 ["Measure", "Value"],
1699 [["Files with symbols", "{:,}".format(len(sym))],
1700 ["Symbols total", "{:,}".format(tot)],
1701 ["Mean per file", "{:.1f}".format(tot / float(max(1, len(sym))))],
1702 ["Files with > 100 symbols", "{:,}".format(sum(1 for r in sym if r["total"] > 100))],
1703 ["Files with > 50 symbols", "{:,}".format(sum(1 for r in sym if r["total"] > 50))]],
1704 ["---", "--:"]))
1705 A("")
1706 A("### Largest interfaces {#ch_symbols_top}")
1707 A("")
1708 A(md_table(
1709 ["Symbols", "Functions", "Variables", "Typedefs", "Macros", "Enums", "File"],
1710 [[r["total"], r["functions"], r["variables"], r["typedefs"],
1711 r["macros"], r["enums"], r["file"]] for r in sym[:60]],
1712 ["--:", "--:", "--:", "--:", "--:", "--:", "---"]))
1713 A("")
1714 A("The complete per-file table is in `code-health.json`, published next to this page.")
1715 else:
1716 A("_symbol data not available (needs Doxygen's SQLite output)._")
1717 A("")
1718
1719 # ---- static analysis
1720 mod = data.get("modularity")
1721 A("Modularity {#ch_modularity}")
1722 A("----------")
1723 A("")
1724 if mod:
1725 A("Do the folders correspond to how the code is actually coupled?")
1726 A("")
1727 A("The directory layout is treated as a proposed grouping of the dependency graph")
1728 A("and scored with Newman modularity `Q` - the share of edges falling inside")
1729 A("groups, minus what random wiring of the same degrees would give. Then a grouping")
1730 A("is derived from the graph itself, with Louvain, and scored the same way.")
1731 A("")
1732 A("**The gap is the answer.** If directories really were modules, grouping by")
1733 A("directory would be near-optimal and the gap would be small. A large gap means")
1734 A("the code does cluster - just not along the lines it is filed under.")
1735 A("")
1736 A(md_table(
1737 ["Measure", "Value"],
1738 [["Q of the directory layout", mod["q_directories"]],
1739 ["Q of the derived grouping", mod["q_derived"]],
1740 ["Gap", mod["gap"]],
1741 ["Directories", "{:,}".format(mod["directories"])],
1742 ["Derived clusters", "{:,}".format(mod["derived_clusters"])],
1743 ["Files whose directory matches their cluster",
1744 "{:,} ({} %)".format(mod["files_in_agreeing_cluster"], mod["agreement_share"])],
1745 ["Includes staying inside one directory",
1746 "{:,} of {:,} ({} %)".format(mod["intra_directory_includes"],
1747 mod["total_includes"],
1748 mod["intra_directory_share"])]],
1749 ["---", "--:"]))
1750 A("")
1751 A("Largest derived clusters, in files: " +
1752 ", ".join(str(n) for n in mod["largest_clusters"]))
1753 A("")
1754 A("### How to read this, and how not to {#ch_modularity_caveat}")
1755 A("")
1756 A("Modularity rewards COMMUNITY structure - groups with dense internal and sparse")
1757 A("external links. A well-layered codebase is not community-structured, it is")
1758 A("hierarchical, and the two are different shapes. A leaf library factored out")
1759 A("precisely so that everything can use it has, by construction, almost all its")
1760 A("edges crossing a boundary, and `Q` marks it down for exactly the property that")
1761 A("makes it good design.")
1762 A("")
1763 A("So a lower `Q` is not automatically worse, and this metric should not be read")
1764 A("as a verdict the way the cycle and reach figures can be. What it does say")
1765 A("reliably is the GAP: both scores here are far below the 0.3 that usually")
1766 A("indicates real community structure, while the derived grouping clears it. The")
1767 A("code clusters; the folders are not where it clusters. That holds whichever tree")
1768 A("is measured, and it is the honest form of the observation that `src/`")
1769 A("subdirectories are drawers rather than modules.")
1770 A("")
1771 else:
1772 A("_include data not available._")
1773 A("")
1774
1775 sc = data.get("selfcontained")
1776 A("Header self-containment {#ch_selfcontained}")
1777 A("-----------------------")
1778 A("")
1779 if sc:
1780 A("Every header compiled on its own, as a translation unit containing nothing but")
1781 A("an include of itself. A header that fails is relying on whoever includes it")
1782 A("having pulled something in first - the dependency is real and written nowhere,")
1783 A("and it breaks the day someone tidies an include in a file that never mentioned")
1784 A("this header.")
1785 A("")
1786 A("X-macro headers are excluded: they are re-included several times in one")
1787 A("translation unit with different macros defined, so compiling one alone is not a")
1788 A("question that applies.")
1789 A("")
1790 A(md_table(
1791 ["Measure", "Value"],
1792 [["Headers checked", "{:,}".format(sc["headers"])],
1793 ["Self-contained", "{:,}".format(sc["self_contained"])],
1794 ["Share", "{} %".format(sc["share"])],
1795 ["Failing", "{:,}".format(sc["failing_count"])]],
1796 ["---", "--:"]))
1797 A("")
1798 if sc["failing"]:
1799 A(md_table(["Header", "First error"],
1800 [["`%s`" % h, e] for h, e in sc["failing"]],
1801 ["---", "---"]))
1802 A("")
1803 else:
1804 A("_no self-containment report was supplied. It needs a configured build tree, so")
1805 A("the code-health workflow produces it and this build folds it in._")
1806 A("")
1807
1808 doc = data.get("docs")
1809 A("Documentation coverage {#ch_docs}")
1810 A("----------------------")
1811 A("")
1812 if doc:
1813 A("Symbols carrying a brief or detailed description, from Doxygen's own record.")
1814 A("A low figure is not automatically bad - self-explanatory code needs no prose -")
1815 A("but it bounds how much of the API can be understood without reading its")
1816 A("implementation.")
1817 A("")
1818 A(md_table(
1819 ["Measure", "Value"],
1820 [["Symbols", "{:,}".format(doc["symbols"])],
1821 ["Documented", "{:,}".format(doc["documented"])],
1822 ["Coverage", "{} %".format(doc["share"])]],
1823 ["---", "--:"]))
1824 A("")
1825 A(md_table(
1826 ["Kind", "Symbols", "Documented", "Coverage"],
1827 [[k["kind"], "{:,}".format(k["symbols"]), "{:,}".format(k["documented"]),
1828 "{} %".format(k["share"])] for k in doc["by_kind"]],
1829 ["---", "--:", "--:", "--:"]))
1830 A("")
1831 A("Files with the most undocumented symbols:")
1832 A("")
1833 A(md_table(
1834 ["Undocumented", "of", "File"],
1835 [["{:,}".format(w["undocumented"]), "{:,}".format(w["symbols"]), "`%s`" % w["file"]]
1836 for w in doc["worst_files"]],
1837 ["--:", "--:", "---"]))
1838 else:
1839 A("_symbol data not available (needs Doxygen's SQLite output)._")
1840 A("")
1841
1842 g = data.get("git")
1843 A("Change history {#ch_git}")
1844 A("--------------")
1845 A("")
1846 if g:
1847 A("Process metrics, over the last %d days. These predict defects better than" % g["days"])
1848 A("static complexity does, and nothing else in this panel can see them: they are")
1849 A("not a property of the code as it stands but of how it got there. Complex code")
1850 A("nobody touches is harmless; complex code changed weekly is where bugs live.")
1851 A("")
1852 A(md_table(
1853 ["Measure", "Value"],
1854 [["Commits", "{:,}".format(g["commits"])],
1855 ["Files touched", "{:,}".format(g["files_touched"])],
1856 ["Lines added + deleted", "{:,}".format(g["total_churn"])],
1857 ["Authors per file, mean", g["mean_authors"]],
1858 ["Authors per file, most", "{:,}".format(g["max_authors"])],
1859 ["Files with a single author", "{:,}".format(g["single_author_files"])]],
1860 ["---", "--:"]))
1861 A("")
1862 A("### Hotspots {#ch_git_hotspots}")
1863 A("")
1864 A("Revisions multiplied by cyclomatic complexity. This is the prioritisation")
1865 A("metric: what to refactor first, rather than what is merely large. A file high")
1866 A("on this list is both hard to reason about and constantly being reasoned about.")
1867 A("")
1868 A(md_table(
1869 ["Score", "Revisions", "CCN", "Churn", "File"],
1870 [["{:,}".format(h["score"]), h["revisions"], "{:,}".format(h["ccn"]),
1871 "{:,}".format(h["churn"]), "`%s`" % h["file"]] for h in g["hotspots"]],
1872 ["--:", "--:", "--:", "--:", "---"]))
1873 A("")
1874 A("### Change coupling {#ch_git_coupling}")
1875 A("")
1876 A("Files that keep being changed together. Some of these pairs have no include")
1877 A("edge between them at all, which is coupling no static analysis can find -")
1878 A("a shared assumption, a duplicated constant, two halves of one idea kept in")
1879 A("step by hand. `Confidence` is how often the rarer of the two changes brings")
1880 A("the other with it.")
1881 A("")
1882 A("Commits touching more than 20 files are left out of these counts: a sweeping")
1883 A("rename couples everything it touches to everything else, which says nothing")
1884 A("about the code.")
1885 A("")
1886 A("%d pairs meet the threshold (5+ shared commits, 40%%+ confidence)."
1887 % g["coupled_total"])
1888 A("")
1889 A(md_table(
1890 ["Together", "Confidence", "Pair"],
1891 [[c["together"], "{:.0f} %".format(c["confidence"]), "`%s`" % c["pair"]]
1892 for c in g["coupled"]],
1893 ["--:", "--:", "---"]))
1894 A("")
1895 A("### Most revised files {#ch_git_revised}")
1896 A("")
1897 A(md_table(
1898 ["Revisions", "Churn", "File"],
1899 [[m["revisions"], "{:,}".format(m["churn"]), "`%s`" % m["file"]]
1900 for m in g["most_revised"]],
1901 ["--:", "--:", "---"]))
1902 else:
1903 A("_no git history available. The documentation job must check out with")
1904 A("`fetch-depth: 0`; a shallow clone has nothing to measure._")
1905 A("")
1906
1907 A("Static analysis {#ch_static}")
1908 A("---------------")
1909 A("")
1910 cpp = data.get("cppcheck")
1911 A("### cppcheck {#ch_cppcheck}")
1912 A("")
1913 if cpp:
1914 A(md_table(["Severity", "Findings"],
1915 [[k, "{:,}".format(v)] for k, v in cpp["by_severity"].items()],
1916 ["---", "--:"]))
1917 A("")
1918 A(md_table(["Findings", "Rule"],
1919 [["{:,}".format(n), "`%s`" % r] for r, n in cpp["top_rules"]],
1920 ["--:", "---"]))
1921 else:
1922 A("_cppcheck not available._")
1923 A("")
1924 ct = data.get("clang_tidy")
1925 A("### clang-tidy {#ch_clang_tidy}")
1926 A("")
1927 if ct:
1928 A(md_table(["Measure", "Value"],
1929 [["Findings", "{:,}".format(ct["total"])],
1930 ["Files with findings", "{:,}".format(ct["files_with_findings"])]],
1931 ["---", "--:"]))
1932 A("")
1933 A(md_table(["Findings", "Check"],
1934 [["{:,}".format(n), "`%s`" % c] for c, n in ct["top_checks"]],
1935 ["--:", "---"]))
1936 else:
1937 A("_No clang-tidy report was supplied to this build. clang-tidy needs a configured")
1938 A("build tree (`compile_commands.json`), which the documentation job does not")
1939 A("produce; the separate code-health workflow supplies it when it has run._")
1940 A("")
1941 A("Elsewhere {#ch_elsewhere}")
1942 A("---------")
1943 A("")
1944 A("SonarCloud carries the findings this panel does not: rule-level issues, duplication,")
1945 A("cognitive complexity and technical debt, with the same third-party exclusions.")
1946 A("")
1947 A("- darktable 5.0: <https://sonarcloud.io/project/overview?id=aurelienpierreeng_darktable-5>")
1948 A("- Ansel: <https://sonarcloud.io/project/overview?id=aurelienpierreeng_ansel>")
1949 A("")
1950 return "\n".join(L) + "\n"
1951
1952
1953def main():
1954 ap = argparse.ArgumentParser(description=__doc__,
1956 ap.add_argument("--project", default="project")
1957 ap.add_argument("--source-dir", default="src")
1958 ap.add_argument("--repo-root", default=".",
1959 help="where to read .gitmodules from (default: cwd)")
1960 ap.add_argument("--db", default="doc/api/sqlite3/doxygen_sqlite3.db")
1961 ap.add_argument("--clang-tidy-log", default=None)
1962 ap.add_argument("--selfcontained-report", default=None,
1963 help="JSON from tools/check_header_selfcontained.py")
1964 ap.add_argument("--out-md", default="doc/code-health.md")
1965 ap.add_argument("--out-json", default="doc/code-health.json")
1966 ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 2)))
1967 ap.add_argument("--history-days", type=int, default=365,
1968 help="window for the git evolution metrics (default: one year)")
1969 ap.add_argument("--skip-cppcheck", action="store_true")
1970 args = ap.parse_args()
1971
1973 sys.stderr.write("code_health: excluding %d submodule(s): %s\n"
1974 % (len(added), ", ".join(added) or "none"))
1975
1976 data = {"project": args.project, "excluded_submodules": added}
1977
1978 def step(name, fn):
1979 sys.stderr.write("code_health: %s ... " % name)
1981 try:
1982 v = fn()
1983 except Exception as exc: # never fail the docs build
1984 sys.stderr.write("failed (%s)\n" % exc)
1985 return None
1986 sys.stderr.write("ok\n" if v else "unavailable\n")
1987 return v
1988
1989 data["cloc"] = step("cloc", lambda: collect_cloc(args.source_dir))
1990 data["ccn"] = step("lizard", lambda: collect_ccn(args.source_dir))
1991 data["symbols"] = step("symbols", lambda: collect_symbols(args.db))
1992 dox_edges = step("includes (doxygen)", lambda: include_edges(args.db))
1993 src_edges = step("includes (source)",
1995 merged = sorted(set(dox_edges or []) | set(src_edges or []))
1996 sys.stderr.write("code_health: include graph %d doxygen + %d source -> %d union\n"
1997 % (len(dox_edges or []), len(src_edges or []), len(merged)))
1998 edges = merged or None
1999 data["include_graph"] = {
2000 "doxygen_edges": len(dox_edges or []),
2001 "source_edges": len(src_edges or []),
2002 "union_edges": len(merged),
2003 }
2004 data["includers"] = step("fan-in", lambda: collect_includers(edges))
2005 data["layering"] = step("layering", lambda: collect_layering(edges, args.source_dir))
2006 data["reach"] = step("transitive reach", lambda: collect_reach(edges))
2007 data["modularity"] = step("modularity",
2008 lambda: collect_modularity(edges, args.source_dir))
2009 data["selfcontained"] = step("header self-containment",
2011 data["docs"] = step("doc coverage", lambda: collect_docs(args.db))
2012 data["git"] = step("git history",
2014 (data.get("ccn") or {}).get("per_file_ccn", {})))
2015 data["god_header"] = step("global header", lambda: collect_god_header(edges))
2016 if not args.skip_cppcheck:
2017 data["cppcheck"] = step("cppcheck",
2019 data["clang_tidy"] = step("clang-tidy", lambda: collect_clang_tidy(args.clang_tidy_log))
2020
2021 for path, payload in ((args.out_json, json.dumps(data, indent=1, sort_keys=True)),
2023 d = os.path.dirname(path)
2024 if d:
2025 os.makedirs(d, exist_ok=True)
2026 with open(path, "w", encoding="utf-8") as fh:
2027 fh.write(payload)
2028 sys.stderr.write("code_health: wrote %s\n" % path)
2029 return 0
2030
2031
2032if __name__ == "__main__":
2033 sys.exit(main())
#define A(y, x)
static const float const float const float min
const float max
const dt_collection_sort_t items[]
Definition filter.c:101
static float * partition(float *first, float *last, float val)
derive_layering(edges, label)
collect_layering(edges, source_dir="src")
collect_reach(edges)
compute_stability(mod_edges)
run(cmd, **kw)
is_production_file(path)
collect_docs(db_path)
build_markdown(project, data)
load_submodule_exclusions(repo_root=".")
strongly_connected(nodes, succ)
collect_ccn(source_dir)
collect_god_header(edges)
module_of(path, source_dir="src")
collect_cppcheck(source_dir, jobs)
collect_cloc(source_dir)
collect_git(repo_root, days, per_file_ccn, max_files_per_commit=20)
collect_symbols(db_path)
_modularity(adj_w, degree, two_m, partition)
collect_selfcontained(path)
collect_includers(edges)
collect_clang_tidy(log_path)
source_include_edges(repo_root, source_dir="src")
include_edges(db_path)
feedback_arc_order(edges)
md_table(headers, rows, aligns=None)
is_excluded(path)
collect_modularity(edges, source_dir="src")
_louvain(adj_w, degree, two_m, passes=12)