Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
include_graph.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Static analysis of the project's #include graph.
3
4Run from the repository root: python3 tools/include_graph.py
5
6Builds the DIRECT include graph from source (project includes only), then reports:
7 1. cycles (strongly connected components > 1) -- these are exactly what #pragma once
8 hides; the guards can only be removed once this section reports none;
9 2. layering violations against the declared layer order below;
10 3. god-headers by transitive fan-in (how many files rebuild when you touch it);
11 4. headers with the largest transitive closure (what including one costs).
12
13It reads sources, not the build, so it needs no compilation and covers every
14configuration at once -- including the #ifdef branches your own build does not take.
15The trade-off is that it counts includes inside conditional blocks unconditionally.
16For a single translation unit's REAL expansion, use the compiler instead:
17 gcc -H -fsyntax-only <flags from build/compile_commands.json> file.c
18and for unused includes, clang-include-cleaner or include-what-you-use.
19"""
20import os, re, sys
21from collections import defaultdict
22
23SRC = 'src'
24INCLUDE_RE = re.compile(r'^\s*#\s*include\s+"([^"]+)"', re.M)
25
26# layer order: a file may include its own layer and any layer BELOW it (higher index = higher level)
27# GUI toolkit code (gui/, dtgtk/, bauhaus/) is INFRASTRUCTURE used by modules, so it sits
28# below them, not above: an iop's gui_init() legitimately calls gtk/bauhaus helpers.
29LAYERS = [
30 ('external', 0), ('win', 0), ('system', 0),
31 ('common', 1), ('math', 1), ('colorprofiles', 1),
32 # pixel/: image-processing primitives (wavelets, guided filters, colour adaptation,
33 # interpolation). Above common/ because they are a domain library rather than
34 # infrastructure, below control/ because they must never reach the control loop.
35 # metadata/: what a photograph says about itself -- EXIF/IPTC/XMP, ratings, colour
36 # labels, tags, geotags. Layer 1 measured, not assumed: at layer 2 the move costs +20
37 # violations, because its consumers (common/, caches/) sit at 1.
38 ('caches', 1), ('database', 1), ('metadata', 1), ('history', 1),
39 ('pixel', 2),
40 # widgets/: reusable GTK widgets that hold no application state. It was at 4, beside gui/,
41 # on the assumption that "GTK" and "the application's GUI" are one layer. They are not:
42 # widgets/ depends only on system/, common/, metadata/ and pixel/ (focus_peaking.c reads
43 # pixel/eigf.h), so it is a leaf library that happens to be written against GTK, and 2.5 --
44 # above pixel/, below control/ -- is where its own dependencies already put it. Measured,
45 # not assumed: the move creates ZERO new violations and removes three (control/ -> widgets/),
46 # 187 -> 184. At 1.5 it would cost one, because pixel/ would then be above it.
47 #
48 # It does NOT follow that a lower layer may now use GTK freely. Dependency order and
49 # toolkit-freedom are different properties and this table measures only the first; the
50 # second is what the Qt port needs and belongs in its own gate.
51 ('widgets', 2.5),
52 ('control', 3),
53 ('gui', 4),
54 ('develop', 5),
55 ('iop', 6), ('imageio', 6),
56 ('libs', 7), ('views', 7), ('chart', 7),
57 ('apps', 10), # executables link the orchestrator, so they sit ABOVE it
58 ('app', 9), # main.c, darktable.c/h -- directly in src/
59]
60LAYER = dict(LAYERS)
61
62def layer_of(path):
63 parts = path.split(os.sep)
64 if len(parts) < 2:
65 return None
66 # A file directly in src/ (main.c, darktable.c/h) is the application root: the
67 # orchestrator sits ABOVE every module, so nothing it includes can be an inversion.
68 if len(parts) == 2:
69 return LAYER['app']
70 return LAYER.get(parts[1])
71
72def collect():
73 files = {}
74 for root, _, names in os.walk(SRC):
75 parts = root.split(os.sep)
76 # skip vendored code and archived, non-built directories: neither is part of
77 # the program, and counting attic/ made dead code register as live inversions
78 if 'external' in parts or 'attic' in parts:
79 continue
80 for n in names:
81 if n.endswith(('.c', '.h', '.cc', '.cpp', '.hpp')):
82 p = os.path.join(root, n)
83 try:
84 files[p] = open(p, encoding='utf-8', errors='replace').read()
85 except OSError:
86 pass
87 return files
88
89def resolve(inc, from_path, known):
90 # includes are written relative to src/, or occasionally to the including file's dir
91 cand = os.path.normpath(os.path.join(SRC, inc))
92 if cand in known:
93 return cand
94 cand2 = os.path.normpath(os.path.join(os.path.dirname(from_path), inc))
95 if cand2 in known:
96 return cand2
97 return None
98
99def tarjan(graph, nodes):
100 index, low, on, stack, out, counter = {}, {}, set(), [], [], [0]
101 def strong(v):
102 work = [(v, 0)]
103 while work:
104 node, pi = work[-1]
105 if pi == 0:
106 index[node] = low[node] = counter[0]; counter[0] += 1
107 stack.append(node); on.add(node)
108 recurse = False
109 succs = list(graph.get(node, ()))
110 for i in range(pi, len(succs)):
111 w = succs[i]
112 if w not in index:
113 work[-1] = (node, i + 1); work.append((w, 0)); recurse = True; break
114 elif w in on:
115 low[node] = min(low[node], index[w])
116 if recurse:
117 continue
118 if low[node] == index[node]:
119 comp = []
120 while True:
121 w = stack.pop(); on.discard(w); comp.append(w)
122 if w == node: break
123 out.append(comp)
124 work.pop()
125 if work:
126 parent = work[-1][0]
127 low[parent] = min(low[parent], low[node])
128 for n in nodes:
129 if n not in index:
130 strong(n)
131 return out
132
133def main():
134 files = collect()
135 known = set(files)
136 graph = defaultdict(set)
137 for p, text in files.items():
138 for inc in INCLUDE_RE.findall(text):
139 t = resolve(inc, p, known)
140 if t and t != p:
141 graph[p].add(t)
142
143 headers = [f for f in files if f.endswith(('.h', '.hpp'))]
144
145 print(f"nodes: {len(files)} ({len(headers)} headers) direct edges: {sum(len(v) for v in graph.values())}\n")
146
147 # 1. cycles
148 comps = [c for c in tarjan(graph, list(files)) if len(c) > 1]
149 print(f"=== 1. INCLUDE CYCLES: {len(comps)} ===")
150 for c in sorted(comps, key=len, reverse=True)[:10]:
151 print(f" cycle of {len(c)}:")
152 for f in sorted(c)[:8]:
153 print(f" {f}")
154 if len(c) > 8: print(f" ... +{len(c)-8}")
155 if not comps:
156 print(" none — the include graph is a DAG")
157
158 # 2. layering violations
159 print("\n=== 2. LAYERING VIOLATIONS (a file including something from a HIGHER layer) ===")
160 viol = defaultdict(list)
161 for a, targets in graph.items():
162 la = layer_of(a)
163 if la is None: continue
164 for b in targets:
165 lb = layer_of(b)
166 if lb is None: continue
167 if lb > la:
168 viol[(a.split(os.sep)[1], b.split(os.sep)[1])].append((a, b))
169 for (da, db), items in sorted(viol.items(), key=lambda kv: -len(kv[1])):
170 print(f" {da}/ -> {db}/ : {len(items)}")
171 for a, b in items[:3]:
172 print(f" {a} -> {b}")
173 if not viol:
174 print(" none")
175
176 # 3/4. transitive closures
177 memo = {}
178 def closure(n, seen=None):
179 if n in memo: return memo[n]
180 out, stack = set(), [n]
181 while stack:
182 cur = stack.pop()
183 for w in graph.get(cur, ()):
184 if w not in out:
185 out.add(w); stack.append(w)
186 memo[n] = out
187 return out
188
189 fan_in = defaultdict(int)
190 for f in files:
191 for h in closure(f):
192 fan_in[h] += 1
193 print("\n=== 3. GOD-HEADERS by transitive fan-in (TUs+headers that pull it in) ===")
194 for h, n in sorted(fan_in.items(), key=lambda kv: -kv[1])[:15]:
195 if h.endswith(('.h', '.hpp')):
196 print(f" {n:5d} {h} (drags in {len(closure(h))} project headers)")
197
198 print("\n=== 4. HEAVIEST HEADERS by transitive closure (what including it costs) ===")
199 for h in sorted(headers, key=lambda x: -len(closure(x)))[:12]:
200 print(f" {len(closure(h)):5d} {h}")
201
202 if '--summary' in sys.argv:
203 print("\n=== SUMMARY ===")
204 summary(files, graph, headers, closure, comps, viol)
205 if '--what-if' in sys.argv:
206 what_if(files, graph)
207 if '--mermaid' in sys.argv:
208 print("\n=== DIRECTORY GRAPH (dotted = layering inversion) ===")
209 mermaid(graph, set(viol.keys()))
210
211
212
213def what_if(files, graph):
214 """Re-count layering violations as if some files lived elsewhere.
215
216 Relocation is cheap to do and expensive to undo, and intuition is unreliable here:
217 moving the history/* cluster into develop/ LOOKS obviously right (it calls
218 dt_dev_* constantly) and measures at +15 violations, because its own consumers sit
219 below develop/. Simulate first.
220
221 Usage: --what-if src/common/foo.c=develop src/common/foo.h=develop
222 """
223 moves = {}
224 for a in sys.argv:
225 if a.startswith('src/') and '=' in a:
226 src, dst = a.split('=', 1)
227 moves[os.path.normpath(src)] = dst
228 if not moves:
229 print(' pass moves as src/path/file.c=destdir')
230 return
231
232 def home(p, mv):
233 p = os.path.normpath(p)
234 if p in mv:
235 return mv[p]
236 parts = p.split(os.sep)
237 return parts[1] if len(parts) > 1 else None
238
239 def count(mv):
240 n = 0
241 for a, targets in graph.items():
242 la = LAYER.get(home(a, mv))
243 if la is None:
244 continue
245 for b in targets:
246 lb = LAYER.get(home(b, mv))
247 if lb is not None and lb > la:
248 n += 1
249 return n
250
251 base = count({})
252 after = count(moves)
253 print('\n=== WHAT-IF ===')
254 for s_, d in moves.items():
255 print(' %s -> %s/' % (s_, d))
256 print(' layering violations %d -> %d (%+d)' % (base, after, after - base))
257
258
259def summary(files, graph, headers, closure, comps, viol):
260 """One-line-per-metric output, for before/after comparison."""
261 orphan_headers = [h for h in headers if not any(h in graph.get(f, ()) for f in files)]
262 print(f"nodes\t{len(files)}")
263 print(f"headers\t{len(headers)}")
264 print(f"direct_edges\t{sum(len(v) for v in graph.values())}")
265 print(f"cycles\t{len(comps)}")
266 print(f"cycle_nodes\t{sum(len(c) for c in comps)}")
267 print(f"layering_violations\t{sum(len(v) for v in viol.values())}")
268 print(f"max_closure\t{max((len(closure(h)) for h in headers), default=0)}")
269 print(f"mean_closure\t{sum(len(closure(h)) for h in headers) / max(len(headers), 1):.1f}")
270 tot = sum(len(closure(f)) for f in files)
271 print(f"total_transitive_edges\t{tot}")
272
273 # Per-TU cost is the metric that actually tracks compile-time coupling. The raw
274 # totals above are SUMS over all nodes, so splitting one god-header into several
275 # small ones inflates them even as every individual file gets cheaper -- use these
276 # for before/after comparison instead.
277 tus = [f for f in files if f.endswith(('.c', '.cc', '.cpp'))]
278 tu_costs = sorted(len(closure(f)) for f in tus)
279 if tu_costs:
280 print(f"tus\t{len(tus)}")
281 print(f"tu_mean_closure\t{sum(tu_costs) / len(tu_costs):.1f}")
282 print(f"tu_median_closure\t{tu_costs[len(tu_costs) // 2]}")
283 print(f"tu_max_closure\t{tu_costs[-1]}")
284
285 # Counting NODES rewards monoliths: one 1300-line god-header is a single node,
286 # while the same content split into 11 honest headers counts as up to 11. Weigh
287 # each header by its own line count to measure what the compiler actually eats.
288 lines = {}
289 for f in files:
290 try:
291 lines[f] = sum(1 for _ in open(f, encoding='utf-8', errors='replace'))
292 except OSError:
293 lines[f] = 0
294 tu_line_costs = sorted(sum(lines.get(h, 0) for h in closure(f)) for f in tus)
295 if tu_line_costs:
296 print(f"tu_mean_closure_lines\t{sum(tu_line_costs) // len(tu_line_costs)}")
297 print(f"tu_median_closure_lines\t{tu_line_costs[len(tu_line_costs) // 2]}")
298 print(f"tu_max_closure_lines\t{tu_line_costs[-1]}")
299
300 # How far the application orchestrator still reaches.
301 dt_h = os.path.join(SRC, 'darktable.h') # the orchestrator now lives at src/
302 if dt_h in files:
303 reach = sum(1 for f in files if dt_h in closure(f))
304 direct = sum(1 for f in files if dt_h in graph.get(f, ()))
305 print(f"darktable_h_reach\t{reach}")
306 print(f"darktable_h_direct_includers\t{direct}")
307 print(f"darktable_h_closure\t{len(closure(dt_h))}")
308
309def mermaid(graph, viol_pairs):
310 """Directory-level aggregate, renderable inline in a GitHub comment."""
311 agg = defaultdict(int)
312 for a, targets in graph.items():
313 da = a.split(os.sep)[1] if len(a.split(os.sep)) > 1 else '?'
314 for b in targets:
315 db = b.split(os.sep)[1] if len(b.split(os.sep)) > 1 else '?'
316 if da != db:
317 agg[(da, db)] += 1
318 print("```mermaid")
319 print("graph LR")
320 for (a, b), n in sorted(agg.items(), key=lambda kv: -kv[1]):
321 if n < 5:
322 continue
323 bad = (a, b) in viol_pairs
324 arrow = "-. %d .->" % n if bad else "-- %d -->" % n
325 print(f" {a} {arrow} {b}")
326 print("```")
327
328if __name__ == '__main__':
329 main()
static const float const float const float min
mermaid(graph, viol_pairs)
tarjan(graph, nodes)
summary(files, graph, headers, closure, comps, viol)
what_if(files, graph)
resolve(inc, from_path, known)