Ansel 0.0
A darktable fork - bloat + design vision
Loading...
Searching...
No Matches
decl_def_audit.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Find functions declared in one module's header but defined in another module's source.
3
4The convention this checks is `example.h` declares, `example.c` defines. Where a definition
5sits somewhere else, the definition is not where anyone looks for it and nothing warns --
6`common/history_merge.c` carried two such cases in opposite directions, and neither surfaced
7until an unrelated include had to be removed (roadmap section 13).
8
9Parsing is Universal Ctags, not a regex: prototypes (kind `prototype`) are declarations,
10`function` tags in a .c/.cc are definitions. Requires ctags on PATH; this is an audit that
11produces a list to read, not a CI gate, so that dependency is fine.
12
13Usage:
14 tools/decl_def_audit.py [--all] [--json]
15
16By default only *cross-module* mismatches are reported -- a definition in a different
17directory than its declaring header, which is where the real traps live. --all additionally
18lists same-directory mismatches (declared in a.h, defined in b.c next door), which are often
19deliberate.
20"""
21
22import collections
23import json
24import os
25import subprocess
26import sys
27
28REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
29SRC = os.path.join(REPO, "src")
30
31
33 cmd = [
34 "ctags", "--languages=C,C++",
35 "--kinds-C=+p", "--kinds-C++=+p",
36 "--fields=+n", "--output-format=json",
37 "-R", "--exclude=external", "--exclude=build", "src/",
38 ]
39 try:
40 out = subprocess.run(cmd, cwd=REPO, capture_output=True, text=True, check=True).stdout
41 except FileNotFoundError:
42 sys.exit("error: ctags not found. Install universal-ctags.")
43 except subprocess.CalledProcessError as e:
44 sys.exit(f"error: ctags failed: {e.stderr[:400]}")
45
46 for line in out.splitlines():
47 if not line.startswith("{"):
48 continue
49 try:
50 yield json.loads(line)
51 except json.JSONDecodeError:
52 continue
53
54
55def module_of(path):
56 """The 'module' a file belongs to: its directory plus its basename without extension."""
57 d = os.path.dirname(path)
58 stem = os.path.basename(path)
59 for ext in (".h", ".hpp", ".c", ".cc", ".cpp"):
60 if stem.endswith(ext):
61 stem = stem[: -len(ext)]
62 break
63 return d, stem
64
65
66# Not every cross-directory definition is a mistake, and reporting them as one buries the few
67# that are. Three shapes recur and each means something different.
68ORCHESTRATOR = "defined in darktable.c: the singleton-accessor pattern"
69SUBDIR = "implementation lives in a subdirectory of the header"
70PARENTDIR = "header lives in a subdirectory of the implementation"
71UNRELATED = "unrelated directories"
72
73
74def categorise(hdr, src):
75 hdir, sdir = os.path.dirname(hdr), os.path.dirname(src)
76 if src == "src/darktable.c":
77 return ORCHESTRATOR
78 if sdir.startswith(hdir + "/"):
79 return SUBDIR
80 if hdir.startswith(sdir + "/"):
81 return PARENTDIR
82 return UNRELATED
83
84
85def main():
86 show_all = "--all" in sys.argv
87 as_json = "--json" in sys.argv
88
89 decls = collections.defaultdict(list) # name -> [(path, line)]
90 defs = collections.defaultdict(list)
91
92 for tag in run_ctags():
93 path, name, kind = tag.get("path", ""), tag.get("name", ""), tag.get("kind", "")
94 # Ignore anything nested in a class/struct/namespace: C++ members follow their own
95 # placement rules and are not what this convention is about.
96 if tag.get("scope"):
97 continue
98 if kind == "prototype" and path.endswith((".h", ".hpp")):
99 decls[name].append((path, tag.get("line", 0)))
100 elif kind == "function" and path.endswith((".c", ".cc", ".cpp")):
101 # ctags marks file-scoped (static) definitions with "file": true. A static
102 # function cannot be what a header declaration resolves to, and skipping them
103 # removes a whole class of false positive from generic names -- two unrelated
104 # files each with a static swap() looked like a mismatch against
105 # common/points.h's declaration of the same name.
106 if tag.get("file"):
107 continue
108 defs[name].append((path, tag.get("line", 0)))
109
110 # A symbol one header declares and many sources define is a plugin interface, not a
111 # misplaced definition: every IOP defines tiling_callback() against develop/tiling.h, and
112 # every entry point defines main() against win/main_wrapper.h. Reporting those buries the
113 # real findings under a hundred lines of by-design.
114 INTERFACE_MIN_IMPLS = 3
115
116 findings = []
117 for name, places in sorted(decls.items()):
118 if name not in defs:
119 continue # declared but not defined here: another audit
120 if len(defs[name]) >= INTERFACE_MIN_IMPLS:
121 continue # an interface with many implementors
122 for hdr, hline in places:
123 hdir, hstem = module_of(hdr)
124 # Defined in the sibling source? Then it follows the convention; nothing to say.
125 if any(module_of(p) == (hdir, hstem) for p, _ in defs[name]):
126 continue
127 for src, sline in defs[name]:
128 sdir, _ = module_of(src)
129 cross = sdir != hdir
130 if cross or show_all:
131 findings.append({
132 "symbol": name,
133 "declared_in": f"{hdr}:{hline}",
134 "defined_in": f"{src}:{sline}",
135 "cross_module": cross,
136 "category": categorise(hdr, src),
137 })
138
139 if as_json:
140 print(json.dumps(findings, indent=2))
141 return 0
142
143 cross = [f for f in findings if f["cross_module"]]
144 same = [f for f in findings if not f["cross_module"]]
145
146 by_cat = collections.Counter(f["category"] for f in cross)
147 print(f"{len(cross)} cross-directory mismatch(es)"
148 + (f", {len(same)} same-directory" if show_all else "")
149 + "\n")
150 for cat, n in by_cat.most_common():
151 print(f" {n:4d} {cat}")
152 print()
153
154 # Only the last category is listed symbol by symbol. The other three are structural: they
155 # describe how a module is laid out, not a definition anyone will fail to find. They are
156 # summarised by header/source pair so a genuinely odd one still stands out.
157 for cat in (SUBDIR, PARENTDIR, ORCHESTRATOR):
158 rows = [f for f in cross if f["category"] == cat]
159 if not rows:
160 continue
161 pairs = collections.Counter(
162 (f["declared_in"].split(":")[0], f["defined_in"].split(":")[0]) for f in rows
163 )
164 print(f"--- {cat} ({len(rows)}) ---")
165 for (h, s), n in pairs.most_common():
166 print(f" {n:3d} {h} -> {s}")
167 print()
168
169 rows = [f for f in cross if f["category"] == UNRELATED]
170 print(f"--- {UNRELATED} ({len(rows)}) : the ones worth looking at ---")
171 for f in sorted(rows, key=lambda x: (x["declared_in"], x["symbol"])):
172 print(f" {f['symbol']}")
173 print(f" declared {f['declared_in']}")
174 print(f" defined {f['defined_in']}")
175
176 if show_all and same:
177 print("\n--- same directory, different file (often deliberate) ---")
178 for f in sorted(same, key=lambda x: (x["declared_in"], x["symbol"])):
179 print(f" {f['symbol']}: {f['declared_in']} -> {f['defined_in']}")
180
181 return 0
182
183
184if __name__ == "__main__":
185 sys.exit(main())
categorise(hdr, src)