cg-code-graph 0.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
codegraph/platforms.py ADDED
@@ -0,0 +1,1369 @@
1
+ """Platform-specific code and multi-target builds: platform tags, the --platform filter and the divergence query.
2
+
3
+ One source tree often builds several targets (Windows / Linux / macOS from one Rust or C code base, iOS / Android /
4
+ web from one Flutter or React Native app). The index records which targets each piece of code exists on:
5
+
6
+ node.attrs / edge.attrs platforms targets the code (or the reference) exists on; absent = every target
7
+ platform_expr the source condition(s), e.g. `cfg(windows)`, `#if defined(__APPLE__)`
8
+ platform_at file:line of the condition
9
+ platform_unknown targets where part of the condition could not be evaluated (kept live)
10
+
11
+ Each language layer adds the conditions of its own idioms (see docs/platforms.md):
12
+
13
+ Rust #[cfg(...)] on items, `mod` declarations, statements and `#![cfg]`; `if cfg!(...)` branches
14
+ C / C++ #if / #ifdef / #elif regions over the platform macros (_WIN32, __APPLE__ + TARGET_OS_*, __linux__,
15
+ __ANDROID__, __EMSCRIPTEN__, BSDs); platform directories and file names (src/win/, unix/, foo_linux.c)
16
+ Dart conditional imports and exports (`import 'a.dart' if (dart.library.io) 'b.dart'`),
17
+ Platform.isX / kIsWeb / defaultTargetPlatform branches, ternaries and switches
18
+ TypeScript React Native / Expo platform files (.ios.ts, .android.ts, .native.ts, .web.ts, also tsx / js / jsx),
19
+ Platform.OS checks, Platform.select({...}), Node / Electron process.platform checks
20
+ any .cg.yaml platforms.paths globs
21
+
22
+ A condition is evaluated three-valued for every known target (True / False / unknown): a part cg cannot evaluate
23
+ (a feature flag, `HAVE_SOUND`, an arch check) keeps the code live on that target and is listed as unknown.
24
+
25
+ Variant implementations (one symbol defined per platform: `#[cfg(windows)] fn config_dir` + `#[cfg(unix)] fn
26
+ config_dir`, storage.ios.ts + storage.android.ts, a conditional-import stub and its io / web libraries) are grouped:
27
+ every reference to one variant also reaches the others, each tagged with its own platforms, so `impact` / `reaches`
28
+ on any target follow the implementation that target builds.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import fnmatch
33
+ import json
34
+ import os
35
+ import re
36
+ from collections import defaultdict
37
+ from pathlib import Path
38
+ from . import presets
39
+
40
+ # dependency / VCS directories every walk skips (codegraph/presets/common.yaml skip_dirs)
41
+ COMMON_SKIP = presets.skip_dirs("common")
42
+
43
+ KNOWN = ("windows", "linux", "macos", "ios", "android", "web", "tvos", "watchos", "visionos")
44
+ DESKTOP = ("windows", "linux", "macos")
45
+ APPLE = ("macos", "ios", "tvos", "watchos", "visionos")
46
+ ALIASES = {"win": "windows", "win32": "windows", "win64": "windows", "darwin": "macos", "mac": "macos", "osx": "macos",
47
+ "macosx": "macos", "iphoneos": "ios", "iphone": "ios", "ipados": "ios", "browser": "web", "wasm": "web",
48
+ "wasm32": "web", "js": "web", "emscripten": "web", "appletvos": "tvos", "xros": "visionos",
49
+ "visionos": "visionos", "tvos": "tvos", "watchos": "watchos"}
50
+ APPLE_SRC = re.compile(r"\.(swift|c|h|cc|cpp|cxx|hh|hpp|hxx|m|mm)$") # what an Xcode project compiles
51
+ IPHONE_FAMILY = {"ios", "tvos", "watchos", "visionos"}
52
+ UNIX = ("linux", "macos", "ios", "android", "tvos", "watchos", "visionos")
53
+ BIG = 10 ** 9
54
+
55
+
56
+ def norm(name: str | None) -> str | None:
57
+ """'Darwin' -> 'macos', 'win32' -> 'windows', 'ios' -> 'ios'; None for a name that is not a known target."""
58
+ if not name:
59
+ return None
60
+ n = str(name).strip().lower()
61
+ n = ALIASES.get(n, n)
62
+ return n if n in KNOWN else None
63
+
64
+
65
+ def resolve_platform(name: str) -> str:
66
+ """A --platform value -> known target name; ValueError naming the known targets otherwise."""
67
+ p = norm(name)
68
+ if p is None:
69
+ raise ValueError(f"unknown platform {name!r}: known targets are {', '.join(KNOWN)} "
70
+ f"(aliases: {', '.join(sorted(ALIASES))})")
71
+ return p
72
+
73
+
74
+ # ------------------------------------------------------------------ evaluation: cfg-style trees
75
+ DART_NATIVE_LIBS = {"io", "ffi", "isolate", "cli", "developer", "nativewrappers", "mirrors"}
76
+ DART_WEB_LIBS = {"html", "js", "js_util", "js_interop", "js_interop_unsafe", "web_audio", "web_gl", "indexed_db", "svg",
77
+ "web_sql"}
78
+
79
+
80
+ def eval_atom(key: str, val: str | None, p: str):
81
+ """One condition atom on target p: True / False / None (unknown)."""
82
+ if key == "platform": # Platform.OS === 'ios', Platform.isIOS, kIsWeb, file suffixes
83
+ v = ALIASES.get((val or "").lower(), (val or "").lower())
84
+ return p == v
85
+ if key == "never": # a condition no known target meets (Swift `os(macOS)` in a Catalyst-only app)
86
+ return False
87
+ if key == "unknown_on": # true or false on that target, false elsewhere (#74)
88
+ return None if p == val else False
89
+ if key == "native": # React Native `.native.ts` / Platform.select native: every non-web target
90
+ return p != "web"
91
+ if key == "target_platform": # Flutter TargetPlatform: on the web it is the browser's OS
92
+ return None if p == "web" else p == val
93
+ if key == "dart_library":
94
+ if val in DART_NATIVE_LIBS:
95
+ return p != "web"
96
+ if val in DART_WEB_LIBS:
97
+ return p == "web"
98
+ return None
99
+ if key == "unix":
100
+ return p in UNIX
101
+ if key == "windows":
102
+ return p == "windows"
103
+ if key == "target_os":
104
+ v = ALIASES.get(val or "", val or "")
105
+ if v in ("emscripten", "unknown", "wasi", "none"):
106
+ return None if p == "web" else False
107
+ return p == v
108
+ if key == "target_family":
109
+ if val == "unix":
110
+ return p in UNIX
111
+ if val == "windows":
112
+ return p == "windows"
113
+ if val == "wasm":
114
+ return p == "web"
115
+ return None
116
+ if key == "target_vendor":
117
+ if val == "apple":
118
+ return p in APPLE
119
+ return None
120
+ if key == "target_arch":
121
+ if val in ("wasm32", "wasm64"):
122
+ return p == "web"
123
+ return False if p == "web" else None
124
+ if key == "target_env" and val == "msvc":
125
+ return None if p == "windows" else False
126
+ return None
127
+
128
+
129
+ def eval_tree(t, p: str):
130
+ k = t[0]
131
+ if k == "atom":
132
+ return eval_atom(t[1], t[2], p)
133
+ if k == "not":
134
+ v = eval_tree(t[1], p)
135
+ return None if v is None else (not v)
136
+ vals = [eval_tree(x, p) for x in t[1]]
137
+ if k == "all":
138
+ if any(v is False for v in vals):
139
+ return False
140
+ return True if all(v is True for v in vals) else None
141
+ if any(v is True for v in vals):
142
+ return True
143
+ return False if vals and all(v is False for v in vals) else None
144
+
145
+
146
+ # ------------------------------------------------------------------ evaluation: C preprocessor
147
+ # macro -> value per target ("1" / "0" defined with that value, None = may or may not be defined); every macro in
148
+ # C_MACROS that a target does not list is undefined there
149
+ C_FACTS: dict[str, dict[str, str | None]] = {
150
+ "windows": {"_WIN32": "1", "WIN32": "1", "__WIN32__": "1", "_WINDOWS": None, "_WIN64": None, "_MSC_VER": None,
151
+ "__MINGW32__": None, "__MINGW64__": None, "__CYGWIN__": None, "WINAPI_FAMILY": None},
152
+ "linux": {"__linux__": "1", "__linux": "1", "linux": None, "__gnu_linux__": "1", "__unix__": "1", "__unix": "1",
153
+ "unix": None, "__GLIBC__": None},
154
+ "android": {"__linux__": "1", "__linux": "1", "linux": None, "__ANDROID__": "1", "ANDROID": None, "__BIONIC__": "1",
155
+ "__unix__": "1", "__unix": "1", "unix": None, "__ANDROID_API__": None},
156
+ "macos": {"__APPLE__": "1", "__MACH__": "1", "TARGET_OS_MAC": "1", "TARGET_OS_OSX": "1", "TARGET_OS_IPHONE": "0",
157
+ "TARGET_OS_IOS": "0", "TARGET_OS_SIMULATOR": "0", "TARGET_IPHONE_SIMULATOR": "0", "TARGET_OS_TV": "0",
158
+ "TARGET_OS_WATCH": "0", "TARGET_OS_VISION": "0", "TARGET_OS_MACCATALYST": "0", "__APPLE_CC__": None},
159
+ "ios": {"__APPLE__": "1", "__MACH__": "1", "TARGET_OS_MAC": "1", "TARGET_OS_OSX": "0", "TARGET_OS_IPHONE": "1",
160
+ "TARGET_OS_IOS": "1", "TARGET_OS_SIMULATOR": None, "TARGET_IPHONE_SIMULATOR": None, "TARGET_OS_TV": "0",
161
+ "TARGET_OS_WATCH": "0", "TARGET_OS_VISION": "0", "TARGET_OS_MACCATALYST": None, "__APPLE_CC__": None},
162
+ "tvos": {"__APPLE__": "1", "__MACH__": "1", "TARGET_OS_MAC": "1", "TARGET_OS_OSX": "0", "TARGET_OS_IPHONE": "1",
163
+ "TARGET_OS_IOS": "0", "TARGET_OS_SIMULATOR": None, "TARGET_IPHONE_SIMULATOR": None, "TARGET_OS_TV": "1",
164
+ "TARGET_OS_WATCH": "0", "TARGET_OS_VISION": "0", "TARGET_OS_MACCATALYST": "0", "__APPLE_CC__": None},
165
+ "watchos": {"__APPLE__": "1", "__MACH__": "1", "TARGET_OS_MAC": "1", "TARGET_OS_OSX": "0", "TARGET_OS_IPHONE": "1",
166
+ "TARGET_OS_IOS": "0", "TARGET_OS_SIMULATOR": None, "TARGET_IPHONE_SIMULATOR": None, "TARGET_OS_TV": "0",
167
+ "TARGET_OS_WATCH": "1", "TARGET_OS_VISION": "0", "TARGET_OS_MACCATALYST": "0", "__APPLE_CC__": None},
168
+ "visionos": {"__APPLE__": "1", "__MACH__": "1", "TARGET_OS_MAC": "1", "TARGET_OS_OSX": "0", "TARGET_OS_IPHONE": "1",
169
+ "TARGET_OS_IOS": "0", "TARGET_OS_SIMULATOR": None, "TARGET_IPHONE_SIMULATOR": None, "TARGET_OS_TV": "0",
170
+ "TARGET_OS_WATCH": "0", "TARGET_OS_VISION": "1", "TARGET_OS_MACCATALYST": "0", "__APPLE_CC__": None},
171
+ "web": {"__EMSCRIPTEN__": "1", "EMSCRIPTEN": None, "__wasm__": "1", "__wasm32__": None, "__wasm64__": None,
172
+ "__unix__": "1", "__unix": "1", "unix": None},
173
+ }
174
+ C_OTHER_OS = ("__FreeBSD__", "__OpenBSD__", "__NetBSD__", "__DragonFly__", "__sun", "__sun__", "_AIX", "__HAIKU__",
175
+ "__Fuchsia__", "__QNX__", "__CYGWIN32__", "__MSYS__", "__hpux", "__GNU__", "__OS400__", "__VMS", "__MVS__",
176
+ "__ZEPHYR__", "__redox__")
177
+ C_MACROS = set(C_OTHER_OS) | {m for f in C_FACTS.values() for m in f}
178
+
179
+
180
+ def eval_c(expr: str, p: str):
181
+ from .plugins.native.gates import eval_pp
182
+ facts = C_FACTS[p]
183
+ defines = {m: v for m, v in facts.items() if v is not None}
184
+ undefined = {m for m in C_MACROS if m not in facts}
185
+ return eval_pp(expr, defines, undefined)
186
+
187
+
188
+ def c_relevant(expr: str) -> bool:
189
+ return any(m in C_MACROS for m in re.findall(r"[A-Za-z_]\w*", expr))
190
+
191
+
192
+ # ------------------------------------------------------------------ conditions and marks
193
+ class Cond:
194
+ """A platform condition: kind 'tree' (cfg-style tree) or 'c' (preprocessor expression), with its source text."""
195
+ __slots__ = ("kind", "expr", "text", "_v")
196
+
197
+ def __init__(self, kind: str, expr, text: str):
198
+ self.kind, self.expr, self.text = kind, expr, text
199
+ self._v = None
200
+
201
+ def values(self) -> dict[str, bool | None]:
202
+ if self._v is None:
203
+ if self.kind == "c":
204
+ self._v = {p: eval_c(self.expr, p) for p in KNOWN}
205
+ else:
206
+ self._v = {p: eval_tree(self.expr, p) for p in KNOWN}
207
+ return self._v
208
+
209
+ def relevant(self) -> bool:
210
+ """Does the condition tell targets apart (some target False, or True on some and unknown on others)?"""
211
+ v = self.values()
212
+ if any(x is False for x in v.values()):
213
+ return True
214
+ return False
215
+
216
+
217
+ def cfg_cond(pred: str, text: str | None = None) -> Cond:
218
+ from .plugins.native.gates import parse_cfg
219
+ return Cond("tree", parse_cfg(pred), text or f"cfg({pred})")
220
+
221
+
222
+ def mark(builder, file: str, start: int, end: int, cond: Cond, line: int | None = None, scol: int | None = None,
223
+ ecol: int | None = None, nodes: bool = True) -> None:
224
+ """Record that code in file[start:end] (1-based lines, inclusive; optional 0-based columns on the first / last
225
+ line) exists only where `cond` holds. Applied to nodes defined there (nodes=True) and references made there."""
226
+ if not cond.relevant():
227
+ return
228
+ builder.platform_marks.append({"file": file, "start": start, "end": end, "scol": scol, "ecol": ecol, "cond": cond,
229
+ "line": line or start, "nodes": nodes})
230
+
231
+
232
+ def _combine(vals: list[dict]) -> dict:
233
+ out = {}
234
+ for p in KNOWN:
235
+ xs = [v[p] for v in vals]
236
+ out[p] = False if any(x is False for x in xs) else (True if all(x is True for x in xs) else None)
237
+ return out
238
+
239
+
240
+ # ------------------------------------------------------------------ file conventions
241
+ RN_TEST_SUFFIX_RE = re.compile(r"^(?P<stem>.+?)\.(?P<plat>ios|android|native|web|windows|macos)[.-](?:test|spec)\.[cm]?[jt]sx?$")
242
+ RN_EXPLICIT_IMPORT_RE = re.compile(r"""(?:from|import|require\s*\()\s*['"](\.{1,2}/[^'"]*\.(?:ios|android|native|web|windows|macos))['"]""")
243
+ RN_SUFFIX_RE = re.compile(r"^(?P<stem>.+?)\.(?P<plat>ios|android|native|web|windows|macos)\.(?P<ext>[cm]?[jt]sx?)$")
244
+ RN_DEPS = ("react-native", "expo", "react-native-web", "react-native-windows", "react-native-macos")
245
+ C_EXT = re.compile(r"\.(c|h|cc|cpp|cxx|hh|hpp|hxx|ipp|inl|m|mm)$")
246
+ PATH_DIRS = {"win": "windows", "win32": "windows", "windows": "windows", "unix": "unix", "posix": "unix",
247
+ "linux": "linux", "darwin": "macos", "macos": "macos", "osx": "macos", "mac": "macos", "ios": "ios",
248
+ "android": "android", "emscripten": "web", "wasm": "web"}
249
+ C_SOURCE_EXT = {"c", "cc", "cpp", "cxx", "m", "mm"}
250
+ PATH_STEM = re.compile(r"(?:^|[_-])(win32|win|windows|unix|posix|linux|darwin|macos|osx|ios|android|emscripten)$", re.I)
251
+ # files named after an OS outside the known targets (src/unix/aix.c, os390.c): built for none of them
252
+ OTHER_OS_STEM = re.compile(r"(?:^|[_-])(aix|sunos|solaris|os390|zos|ibmi|freebsd|openbsd|netbsd|dragonfly|haiku|qnx|"
253
+ r"cygwin|hurd|illumos|pase)$", re.I)
254
+
255
+
256
+ def _plat_atom(name: str):
257
+ if name == "other": # an OS outside the known targets
258
+ return ("atom", "platform", "__other__")
259
+ if name == "unix":
260
+ return ("atom", "unix", None)
261
+ if name == "native":
262
+ return ("atom", "native", None)
263
+ return ("atom", "platform", name)
264
+
265
+
266
+ def path_convention(rel: str) -> tuple[str, str] | None:
267
+ """C / C++ platform directories and file names: ('windows', 'directory win/') for src/win/fs.c."""
268
+ parts = rel.split("/")
269
+ stem, _, ext = parts[-1].rpartition(".")
270
+ if ext.lower() in C_SOURCE_EXT: # a header named after a platform (macos.h) is usually included everywhere
271
+ if OTHER_OS_STEM.search(stem):
272
+ return "other", f"file name {parts[-1]}"
273
+ m = PATH_STEM.search(stem)
274
+ if m and not (m.group(1).lower() in ("unix", "posix") and any(d.lower() in PATH_DIRS for d in parts[:-1])):
275
+ w = m.group(1).lower() # src/unix/linux.c: the file name is narrower than its directory
276
+ return PATH_DIRS.get(w, w), f"file name {parts[-1]}"
277
+ for d in parts[:-1]:
278
+ if d.lower() in PATH_DIRS:
279
+ return PATH_DIRS[d.lower()], f"directory {d}/"
280
+ return None
281
+
282
+
283
+ def uses_react_native(root: Path) -> bool:
284
+ for pj in [root / "package.json", *root.glob("*/package.json"), *root.glob("*/*/package.json")]:
285
+ if not COMMON_SKIP.isdisjoint(pj.parts) or not pj.is_file():
286
+ continue
287
+ try:
288
+ d = json.loads(pj.read_text(encoding="utf-8", errors="replace"))
289
+ except (ValueError, OSError):
290
+ continue
291
+ deps = {**(d.get("dependencies") or {}), **(d.get("devDependencies") or {}), **(d.get("peerDependencies") or {})}
292
+ if any(k in deps for k in RN_DEPS):
293
+ return True
294
+ return False
295
+
296
+
297
+ # ------------------------------------------------------------------ targets of the project
298
+ def declared_targets(root: Path, cfg: dict, marks: list, langs: set[str]) -> tuple[list[str], dict[str, str]]:
299
+ """Targets the project builds: .cg.yaml platforms.targets, else Flutter platform folders, the Expo / React
300
+ Native config, Electron / Tauri (desktop), else the desktop targets plus every target a condition names on its
301
+ own (target_os = "android", __EMSCRIPTEN__). Returns (targets, target -> where it came from)."""
302
+ pc = (cfg or {}).get("platforms") or {}
303
+ if pc.get("targets"):
304
+ return list(pc["targets"]), {t: cfg.get("file", ".cg.yaml") for t in pc["targets"]}
305
+ src: dict[str, str] = {}
306
+ for pub in [root / "pubspec.yaml", *root.glob("*/pubspec.yaml"), *root.glob("*/*/pubspec.yaml")]:
307
+ if not pub.is_file() or "flutter" not in pub.read_text(encoding="utf-8", errors="replace"):
308
+ continue
309
+ for d in ("android", "ios", "web", "macos", "windows", "linux"):
310
+ if (pub.parent / d).is_dir():
311
+ src.setdefault(norm(d), f"Flutter folder {(pub.parent / d).relative_to(root).as_posix()}/")
312
+ for app in [root / "app.json", *root.glob("*/app.json"), *root.glob("*/*/app.json")]:
313
+ if not COMMON_SKIP.isdisjoint(app.parts) or not app.is_file():
314
+ continue
315
+ try:
316
+ ex = (json.loads(app.read_text(encoding="utf-8", errors="replace")) or {}).get("expo")
317
+ except (ValueError, OSError, AttributeError):
318
+ continue
319
+ if isinstance(ex, dict):
320
+ for t in ex.get("platforms") or ["ios", "android"]:
321
+ if norm(t):
322
+ src.setdefault(norm(t), f"{app.relative_to(root).as_posix()} expo.platforms")
323
+ for cap in [*root.glob("capacitor.config.*"), *root.glob("*/capacitor.config.*"), *root.glob("*/*/capacitor.config.*")]:
324
+ if cap.suffix not in (".ts", ".js", ".json") or not COMMON_SKIP.isdisjoint(cap.parts):
325
+ continue
326
+ where = cap.relative_to(root).as_posix()
327
+ for d in ("android", "ios"):
328
+ if (cap.parent / d).is_dir():
329
+ src.setdefault(d, f"Capacitor {d}/ project next to {where}")
330
+ if src:
331
+ src.setdefault("web", f"Capacitor web build ({where})")
332
+ if not src and langs & {"ts", "typescript"} and uses_react_native(root):
333
+ for d in ("ios", "android"):
334
+ src.setdefault(d, "React Native")
335
+ for dep, t in (("react-native-web", "web"), ("react-native-windows", "windows"), ("react-native-macos", "macos")):
336
+ if _has_dep(root, dep):
337
+ src.setdefault(t, f"{dep} dependency")
338
+ if not src: # Kotlin Multiplatform first: its iosApp/ Xcode project is one consumer of it
339
+ _kmp_targets(root, src)
340
+ if not src:
341
+ xcode = _swift_package_targets(root, src)
342
+ if src and not xcode: # SwiftPM platforms: only sets Apple minimums; it builds elsewhere too: the targets
343
+ for m in marks or []: # conditions name (an Xcode app builds only what its project lists)
344
+ if _positive(m["cond"]):
345
+ for p, x in m["cond"].values().items():
346
+ if x is True and p not in src and p in ("linux", "windows", "android", "web"):
347
+ src[p] = f"named by {m['cond'].text} @ {m['file']}:{m['line']}"
348
+ elif src: # an Xcode app next to code in other languages (an android/ module): what that code names
349
+ for m in marks or []:
350
+ only = [p for p, x in m["cond"].values().items() if x is True]
351
+ if (only in (["android"], ["web"]) and only[0] not in src and _positive(m["cond"])
352
+ and not APPLE_SRC.search(m["file"])): # (a build script's win32 check is no app target)
353
+ src[only[0]] = f"named by {m['cond'].text} @ {m['file']}:{m['line']}"
354
+ if not src: # Tauri 2 mobile: src-tauri/gen/android, src-tauri/gen/apple
355
+ for d, t in (("android", "android"), ("apple", "ios")):
356
+ if (root / "src-tauri" / "gen" / d).is_dir():
357
+ src.setdefault(t, f"Tauri mobile project src-tauri/gen/{d}/")
358
+ if src:
359
+ for d in DESKTOP:
360
+ src.setdefault(d, "Tauri desktop app")
361
+ if not src and (_has_dep(root, "electron") or (root / "src-tauri" / "tauri.conf.json").is_file()):
362
+ for d in DESKTOP:
363
+ src.setdefault(d, "Electron / Tauri desktop app")
364
+ if not src and marks and all(m.get("bridge") for m in marks):
365
+ for m in marks: # only native bridge modules are platform-specific: their platforms
366
+ for p, x in m["cond"].values().items():
367
+ if x is True:
368
+ src.setdefault(p, f"native bridge module {m['file']}")
369
+ if not src and marks:
370
+ for d in DESKTOP:
371
+ src[d] = "desktop default (native code conditions)" if langs & {"rust", "c_cpp"} else "desktop default"
372
+ for m in marks:
373
+ v = m["cond"].values()
374
+ only = [p for p, x in v.items() if x is True]
375
+ if "ios" in only and set(only) <= IPHONE_FAMILY:
376
+ only = ["ios"] # TARGET_OS_IPHONE, canImport(UIKit): names iOS (tvOS / watchOS only on their own)
377
+ if len(only) == 1 and only[0] not in src and _positive(m["cond"]):
378
+ src[only[0]] = f"named by {m['cond'].text} @ {m['file']}:{m['line']}"
379
+ order = [p for p in KNOWN if p in src]
380
+ return order, {p: src[p] for p in order}
381
+
382
+
383
+ KMP_TARGET = [(re.compile(r"\b(?:androidTarget|android|androidLibrary|androidNative(?:Arm32|Arm64|X86|X64))\s*[({]"), "android"),
384
+ (re.compile(r"\bios(?:X64|Arm64|SimulatorArm64)?\s*[({]"), "ios"),
385
+ (re.compile(r"\btvos(?:X64|Arm64|SimulatorArm64)?\s*[({]"), "tvos"),
386
+ (re.compile(r"\bwatchos(?:X64|Arm64|SimulatorArm64|Arm32|DeviceArm64)?\s*[({]"), "watchos"),
387
+ (re.compile(r"\bmacos(?:X64|Arm64)\s*[({]"), "macos"),
388
+ (re.compile(r"\blinux(?:X64|Arm64)\s*[({]"), "linux"),
389
+ (re.compile(r"\bmingwX64\s*[({]"), "windows"),
390
+ (re.compile(r"\b(?:js|wasmJs|wasmWasi|wasm)\s*[({]"), "web")]
391
+ # the multiplatform plugin applied: kotlin("multiplatform"), id("org.jetbrains.kotlin.multiplatform"),
392
+ # alias(libs.plugins.kotlin.multiplatform) / alias(libs.plugins.kotlinMultiplatform), apply plugin: 'kotlin-multiplatform'
393
+ _KMP_PLUGIN = re.compile(r"kotlin\s*\(\s*\"multiplatform\"|kotlin\.multiplatform\b|['\"]kotlin-multiplatform['\"]|"
394
+ r"plugins\.(?:kotlin\.)?(?:kotlinM|m)ultiplatform\b|plugins\.kmp\b")
395
+ _KMP_DESKTOP = re.compile(r"\bjvm\s*\(\s*\"desktop\"|compose\.desktop\b|\bcompose\s*\.\s*desktop\s*\{")
396
+
397
+
398
+ def _swift_package_targets(root: Path, src: dict) -> bool:
399
+ """Apple targets from the build files (#74): the platforms of the Xcode projects' targets (SDKROOT,
400
+ SUPPORTED_PLATFORMS, SUPPORTS_MACCATALYST), else SwiftPM `platforms: [.iOS(.v15), .macOS(.v12)]` (the root
401
+ manifest, else the local packages'). True when an Xcode project named them."""
402
+ from .xcode import apple_build
403
+ ab = apple_build(root)
404
+ for p, where in ab["targets"].items():
405
+ src.setdefault(p, where)
406
+ for pk in [root / "Package.swift", *root.glob("*/Package.swift")]:
407
+ if not pk.is_file() or not src:
408
+ continue
409
+ txt = pk.read_text(encoding="utf-8", errors="replace")
410
+ srv = re.search(r"github\.com/(vapor/vapor|hummingbird-project/hummingbird|swift-server/[\w-]+)", txt)
411
+ if srv:
412
+ src.setdefault("linux", f"{pk.relative_to(root).as_posix()} server-side Swift dependency ({srv.group(1)})")
413
+ # an Xcode app without a root manifest builds only what its project lists (a package also builds elsewhere)
414
+ return any(" target " in w for w in ab["targets"].values()) and not (root / "Package.swift").is_file()
415
+
416
+
417
+ def _kmp_targets(root: Path, src: dict) -> None:
418
+ """Kotlin Multiplatform targets declared in the `kotlin { }` block of build.gradle(.kts) (root, one or two levels
419
+ down): androidTarget(), iosArm64(), jvm("desktop"), js(), wasmJs(), linuxX64(), mingwX64(), macosArm64()."""
420
+ files = [p for g in ("build.gradle.kts", "build.gradle", "*/build.gradle.kts", "*/build.gradle",
421
+ "*/*/build.gradle.kts", "*/*/build.gradle") for p in root.glob(g)]
422
+ for f in sorted(set(files)):
423
+ if not f.is_file() or not COMMON_SKIP.isdisjoint(f.relative_to(root).parts):
424
+ continue
425
+ txt = f.read_text(encoding="utf-8", errors="replace")
426
+ if not _KMP_PLUGIN.search(txt):
427
+ continue
428
+ txt = re.sub(r"//[^\n]*|/\*.*?\*/", " ", txt, flags=re.S)
429
+ where = f.relative_to(root).as_posix()
430
+ for rx, plat in KMP_TARGET:
431
+ m = rx.search(txt)
432
+ if m:
433
+ src.setdefault(plat, f"{where} kotlin {{ {m.group(0).rstrip('({ ')}() }}")
434
+ if _KMP_DESKTOP.search(txt):
435
+ for d in DESKTOP:
436
+ src.setdefault(d, f"{where} kotlin {{ jvm(\"desktop\") }}")
437
+
438
+
439
+ def _positive(c: "Cond") -> bool:
440
+ """Does the condition name its target positively (`target_os = "android"`, `defined(__ANDROID__)`)? A fallback
441
+ branch (`not(any(unix, windows))`, `#else`) is true on some exotic target without naming it."""
442
+ if c.kind == "c":
443
+ return "!" not in c.expr
444
+
445
+ def walk(t):
446
+ if not isinstance(t, tuple):
447
+ return True
448
+ if t[0] == "not":
449
+ return False
450
+ if t[0] in ("all", "any"):
451
+ return all(walk(x) for x in t[1])
452
+ return True
453
+ return walk(c.expr)
454
+
455
+
456
+ def _has_dep(root: Path, name: str) -> bool:
457
+ pj = root / "package.json"
458
+ try:
459
+ d = json.loads(pj.read_text(encoding="utf-8"))
460
+ except (ValueError, OSError):
461
+ return False
462
+ return any(name in (d.get(k) or {}) for k in ("dependencies", "devDependencies"))
463
+
464
+
465
+ # ------------------------------------------------------------------ the index pass
466
+ def _local_name(n) -> str:
467
+ nm = n.name or n.id.split(":", 1)[-1]
468
+ for sep in ("#", "::", "."):
469
+ nm = nm.rsplit(sep, 1)[-1]
470
+ return nm.split("@", 1)[0]
471
+
472
+
473
+ def _inside(m: dict, line: int, name: str | None, lines_of) -> bool | None:
474
+ """Is a reference / definition on `line` (to / named `name`) inside mark m? None when undecidable."""
475
+ if line is None:
476
+ return m["start"] <= 1 and m["end"] >= BIG
477
+ if line < m["start"] or line > m["end"]:
478
+ return False
479
+ if m["scol"] is None or (m["start"] < line < m["end"]):
480
+ return True
481
+ txt = lines_of(m["file"], line)
482
+ if txt is None or not name:
483
+ return None
484
+ occ = [x.start() for x in re.finditer(rf"(?<![\w$]){re.escape(name)}(?![\w$])", txt)]
485
+ if not occ:
486
+ return None
487
+ ins = [(line > m["start"] or c >= m["scol"]) and (line < m["end"] or c < m["ecol"]) for c in occ]
488
+ return True if all(ins) else (False if not any(ins) else None)
489
+
490
+
491
+ def apply(project, builder) -> dict:
492
+ """Index pass: collect the platform conditions of every language, tag nodes and edges, link variant
493
+ implementations, and find divergence. Returns the `platforms` index stats (empty dict: no platform-specific code)."""
494
+ cfg = project.options.get("config") or {}
495
+ pc = cfg.get("platforms") or {}
496
+ root = Path(project.root)
497
+ marks = list(builder.platform_marks)
498
+ nodes_by_file: dict[str, list] = defaultdict(list)
499
+ for n in builder.nodes.values():
500
+ if n.file:
501
+ nodes_by_file[n.file].append(n)
502
+ langs = {lang for f, ns in nodes_by_file.items() for lang in (ns[0].lang,)}
503
+ texts: dict[str, list[str] | None] = {}
504
+
505
+ def lines_of(f: str, line: int):
506
+ if f not in texts:
507
+ try:
508
+ texts[f] = (root / f).read_text(encoding="utf-8", errors="replace").split("\n")
509
+ except OSError:
510
+ texts[f] = None
511
+ t = texts[f]
512
+ return t[line - 1] if t is not None and 0 < line <= len(t) else None
513
+
514
+ # source-text checks (Platform.OS, Platform.isX, kIsWeb, cfg!(...), process.platform)
515
+ from .platform_scan import TRIGGERS, scan
516
+ scanned = 0
517
+ for f, ns in nodes_by_file.items():
518
+ lang = {"ts": "ts", "dart": "dart", "rust": "rust"}.get(ns[0].lang or "")
519
+ if not lang or f.endswith(".vue"):
520
+ continue
521
+ try:
522
+ src = (root / f).read_text(encoding="utf-8", errors="replace")
523
+ except OSError:
524
+ continue
525
+ if not TRIGGERS[lang].search(src):
526
+ continue
527
+ texts[f] = src.split("\n")
528
+ scanned += 1
529
+ for sl, sc, el, ec, tree, text, at in scan(src, lang):
530
+ mark(builder, f, sl, el, Cond("tree", tree, text), line=at, scol=sc, ecol=ec)
531
+ marks = builder.platform_marks
532
+ # .cg.yaml platforms.paths and the C / C++ directory / file-name conventions
533
+ user_paths = pc.get("paths") or {}
534
+ for f, ns in nodes_by_file.items():
535
+ hit = next(((g, ts) for g, ts in user_paths.items() if fnmatch.fnmatch(f, g)), None)
536
+ if hit:
537
+ g, ts = hit
538
+ tree = ("any", [_plat_atom(t) for t in ts]) if len(ts) > 1 else _plat_atom(ts[0])
539
+ mark(builder, f, 1, BIG, Cond("tree", tree, f"{cfg.get('file', '.cg.yaml')} platforms.paths {g}"), line=1)
540
+ continue
541
+ if C_EXT.search(f) and pc.get("path_conventions", True) and ns[0].lang in ("c", "cpp"):
542
+ pcv = path_convention(f)
543
+ if pcv:
544
+ mark(builder, f, 1, BIG, Cond("tree", _plat_atom(pcv[0]), pcv[1]), line=1)
545
+ # React Native / Expo platform files
546
+ groups: list[dict] = []
547
+ if "ts" in langs and pc.get("file_suffixes", True) and uses_react_native(root):
548
+ mods = {n.file for n in builder.nodes.values() if n.lang == "ts" and n.kind in ("module", "component") and n.file}
549
+ fam: dict[tuple, dict[str, str]] = defaultdict(dict)
550
+ for f in mods:
551
+ d, _, base = f.rpartition("/")
552
+ mm = RN_SUFFIX_RE.match(base)
553
+ if mm:
554
+ fam[(d, mm.group("stem"))][mm.group("plat")] = f
555
+ for (d, stem), variants in fam.items():
556
+ members = []
557
+ conds = []
558
+ for plat, f in sorted(variants.items()):
559
+ c = Cond("tree", _plat_atom(plat), f".{plat} file")
560
+ members.append((f, c))
561
+ conds.append(_plat_atom(plat))
562
+ mark(builder, f, 1, BIG, c, line=1)
563
+ pre = f"{d}/{stem}" if d else stem
564
+ bases = sorted(x for x in mods if re.fullmatch(re.escape(pre) + r"\.[cm]?[jt]sx?", x))
565
+ for bf in bases[:1]:
566
+ c = Cond("tree", ("not", ("any", conds)) if len(conds) > 1 else ("not", conds[0]),
567
+ f"base file next to {', '.join('.' + p for p in sorted(variants))}")
568
+ members.append((bf, c))
569
+ mark(builder, bf, 1, BIG, c, line=1)
570
+ groups.append({"kind": "platform files", "name": pre, "members": members})
571
+ # test files naming a platform before the test part (`release.web.test.ts`, `x.ios-spec.tsx`): that target's
572
+ # tests, not a variant of anything (#63)
573
+ for f in mods:
574
+ mm = RN_TEST_SUFFIX_RE.match(f.rpartition("/")[2])
575
+ if mm:
576
+ plat = mm.group("plat")
577
+ mark(builder, f, 1, BIG, Cond("tree", _plat_atom(plat), f".{plat} test file"), line=1)
578
+ # `import x from './X.ios'` names one variant: Metro bundles that file wherever the importer is built (#63)
579
+ builder.explicit_variant_imports = _explicit_variant_imports(root, builder, groups)
580
+ # Dart conditional imports / exports
581
+ imp_sites = [s for s in builder.platform_imports if "plain" not in s]
582
+ if imp_sites:
583
+ plain = defaultdict(int)
584
+ for e in builder.edges.values():
585
+ if e.kind == "IMPORTS" and not e.attrs.get("conditional"):
586
+ plain[e.dst] += 1
587
+ for s in builder.platform_imports:
588
+ if "plain" in s:
589
+ plain[f"module:{s['plain']}"] += 1
590
+ per_file: dict[str, list] = defaultdict(list)
591
+ seen_groups = {}
592
+ for s in imp_sites:
593
+ conds, members = [], []
594
+ for name, val, f in s["configs"]:
595
+ atom = _dart_atom(name, val)
596
+ c = ("all", [("not", x) for x in conds] + [atom]) if conds else atom
597
+ conds.append(atom)
598
+ if f:
599
+ members.append((f, Cond("tree", c, f"if ({name}{'' if val in (None, 'true') else ' == ' + repr(val)})")))
600
+ if s.get("default"):
601
+ dc = ("all", [("not", x) for x in conds]) if len(conds) > 1 else ("not", conds[0])
602
+ members.insert(0, (s["default"], Cond("tree", dc, "default of " + " / ".join(n for n, _, _ in s["configs"]))))
603
+ for f, c in members:
604
+ per_file[f].append((c, s))
605
+ key = tuple(f for f, _ in members)
606
+ if key not in seen_groups and len(members) > 1:
607
+ seen_groups[key] = True
608
+ groups.append({"kind": "conditional import", "name": s["default"] or members[0][0], "members": members,
609
+ "at": f"{s['file']}:{s['line']}"})
610
+ for f, cs in per_file.items():
611
+ if plain.get(f"module:{f}"):
612
+ continue # also imported unconditionally: available everywhere
613
+ tree = cs[0][0].expr if len(cs) == 1 else ("any", [c.expr for c, _ in cs])
614
+ mark(builder, f, 1, BIG, Cond("tree", tree, cs[0][0].text + f" ({cs[0][1]['file']}:{cs[0][1]['line']})"), line=1)
615
+ # Xcode target membership: a source file only the targets of some platforms compile (a macOS-only target, a
616
+ # widget extension without Catalyst) exists only there (#74)
617
+ if langs & {"swift", "c", "cpp"} and pc.get("xcode_membership", True):
618
+ from .xcode import apple_build
619
+ ab = apple_build(root)
620
+ every = set(ab["targets"])
621
+ for f, m in sorted(ab["membership"].items()):
622
+ ps = [p for p in m["platforms"] if p in KNOWN]
623
+ if f in nodes_by_file and ps and set(ps) < every:
624
+ tree = ("any", [_plat_atom(p) for p in ps]) if len(ps) > 1 else _plat_atom(ps[0])
625
+ names = ", ".join(m["targets"][:3]) + (" …" if len(m["targets"]) > 3 else "")
626
+ mark(builder, f, 1, BIG, Cond("tree", tree, f"Xcode target membership ({names})"), line=1)
627
+ marks = builder.platform_marks
628
+ if not marks:
629
+ return {}
630
+ by_file = defaultdict(list)
631
+ for m in marks:
632
+ by_file[m["file"]].append(m)
633
+ # 1. nodes defined under a condition
634
+ ntag: dict[str, list] = defaultdict(list)
635
+ for f, ms in by_file.items():
636
+ for n in nodes_by_file.get(f, ()):
637
+ if (n.attrs or {}).get("swift_kind") == "extension":
638
+ continue # `extension URL { }`: the type exists everywhere; its members carry the condition (#74)
639
+ for m in ms:
640
+ if m["nodes"] and _inside(m, n.line, _local_name(n), lines_of):
641
+ ntag[n.id].append(m)
642
+ nvals = {nid: _combine([m["cond"].values() for m in ms]) for nid, ms in ntag.items()}
643
+ # 2. variant implementations: references to one variant reach every variant
644
+ mirrored = _mirror(builder, groups, nvals)
645
+ # 3. references made under a condition
646
+ etag: dict[tuple, list] = {}
647
+ efile = defaultdict(list)
648
+ for k, e in builder.edges.items():
649
+ if e.file in by_file:
650
+ efile[e.file].append((k, e))
651
+ for f, es in efile.items():
652
+ ms = by_file[f]
653
+ for k, e in es:
654
+ dn = builder.nodes.get(e.dst)
655
+ name = _local_name(dn) if dn is not None else e.dst.rsplit("#", 1)[-1].rsplit("::", 1)[-1]
656
+ hit = [m for m in ms if _inside(m, e.line, name, lines_of)]
657
+ if hit:
658
+ etag[k] = hit
659
+ targets, tsrc = declared_targets(root, cfg, marks, set(langs) | ({"c_cpp"} if langs & {"c", "cpp"} else set()))
660
+ _write_attrs(builder, ntag, nvals, etag, targets)
661
+ _variant_platforms(builder, nvals, targets)
662
+ unknown = [m for m in marks if any(m["cond"].values().get(p) is None for p in targets)]
663
+ tagged_files = sorted({m["file"] for m in marks})
664
+ st = {"targets": targets, "target_sources": tsrc, "conditions": len(marks), "files_with_conditions": len(tagged_files),
665
+ "tagged_nodes": len(ntag), "tagged_edges": len(etag), "variant_edges": mirrored, "text_scanned_files": scanned,
666
+ "unevaluated_conditions": len(unknown),
667
+ "unevaluated_per_target": {p: sum(1 for m in unknown if m["cond"].values().get(p) is None) for p in targets},
668
+ "unevaluated_samples": sorted({f"{m['cond'].text} @ {m['file']}:{m['line']}" for m in unknown})[:8]}
669
+ st["per_target"] = _per_target(builder, nodes_by_file, nvals, targets)
670
+ st["divergence"] = divergence_findings(builder, groups, nvals, targets)
671
+ return st
672
+
673
+
674
+ def _dart_atom(name: str, val):
675
+ mm = re.fullmatch(r"dart\.library\.(\w+)", name or "")
676
+ if mm and val in (None, "true"):
677
+ return ("atom", "dart_library", mm.group(1))
678
+ if mm and val == "false":
679
+ return ("not", ("atom", "dart_library", mm.group(1)))
680
+ return ("atom", "?", name)
681
+
682
+
683
+ def _slot(n, f: str):
684
+ """Position of a node inside its file, so the same symbol can be found in a sibling variant file."""
685
+ kind, key = n.id.split(":", 1)
686
+ if key == f:
687
+ return (kind, "")
688
+ if key.startswith(f + "#"):
689
+ return (kind, key[len(f) + 1:])
690
+ return None
691
+
692
+
693
+ def _reexported(builder, f: str, depth: int = 0) -> dict[str, str]:
694
+ """Names a file defines by re-exporting them: module attrs.reexports (TS `export {a as b} from './m'`,
695
+ `export {x}`; Dart `export 'src/x.dart' show f` and `const f = Impl.f` tear-offs in a variant library) and
696
+ attrs.reexports_all (TS `export * from './m'`: the exported symbols of that module)."""
697
+ n = builder.nodes.get(f"module:{f}")
698
+ a = (n.attrs or {}) if n is not None else {}
699
+ out = {k: v for k, v in (a.get("reexports") or {}).items() if v in builder.nodes}
700
+ if depth < 3:
701
+ for mid in a.get("reexports_all") or ():
702
+ mf = mid.split(":", 1)[1] if ":" in mid else mid
703
+ for x in builder.nodes.values():
704
+ if x.file == mf and (x.attrs or {}).get("exported"):
705
+ sl = _slot(x, mf)
706
+ if sl and sl[1] and "." not in sl[1] and "#" not in sl[1]:
707
+ out.setdefault(sl[1], x.id)
708
+ for k, v in _reexported(builder, mf, depth + 1).items():
709
+ out.setdefault(k, v)
710
+ return out
711
+
712
+
713
+ def _explicit_variant_imports(root: Path, builder, groups: list[dict]) -> set[tuple[str, str]]:
714
+ """(importer file, member file) for imports that spell a platform file out (`from './X.ios'`) from outside its
715
+ variant group: the importer gets exactly that file on every target it is built for."""
716
+ members = {f for g in groups if g["kind"] == "platform files" for f, _ in g["members"]}
717
+ if not members:
718
+ return set()
719
+ by_noext = {re.sub(r"\.[cm]?[jt]sx?$", "", f): f for f in members}
720
+ importers = {e.file for e in builder.edges.values() if e.kind == "IMPORTS" and e.file
721
+ and e.dst.startswith("module:") and e.dst[7:] in members}
722
+ out = set()
723
+ for f in importers:
724
+ try:
725
+ src = (root / f).read_text(encoding="utf-8", errors="replace")
726
+ except OSError:
727
+ continue
728
+ d = os.path.dirname(f)
729
+ for m in RN_EXPLICIT_IMPORT_RE.finditer(src):
730
+ tgt = os.path.normpath(os.path.join(d, m.group(1))).replace(os.sep, "/")
731
+ if tgt in by_noext and by_noext[tgt] != f:
732
+ out.add((f, by_noext[tgt]))
733
+ return out
734
+
735
+
736
+ def _mirror(builder, groups: list[dict], nvals: dict) -> int:
737
+ """Edges into one member of a variant group -> the same edge into the matching symbol of every other member."""
738
+ adds = []
739
+ slot_maps = []
740
+ explicit = getattr(builder, "explicit_variant_imports", None) or set()
741
+ for g in groups:
742
+ files = [f for f, _ in g["members"]]
743
+ fset = set(files)
744
+ by_slot: dict[tuple, dict[str, str]] = defaultdict(dict)
745
+ for n in builder.nodes.values():
746
+ if n.file in fset:
747
+ s = _slot(n, n.file)
748
+ if s is not None:
749
+ by_slot[s][n.file] = n.id
750
+ idx = {nid: (s, f) for s, fm in by_slot.items() for f, nid in fm.items()}
751
+ # a name a member re-exports from elsewhere: its variant is the re-exported symbol (not in idx: references
752
+ # to that symbol from outside the group do not go through the group)
753
+ kinds = defaultdict(set)
754
+ for kd, nm in by_slot:
755
+ kinds[nm].add(kd)
756
+ for f in files:
757
+ for nm, nid in _reexported(builder, f).items():
758
+ for kd in kinds.get(nm) or (builder.nodes[nid].kind,): # a tear-off of a method stands for a function
759
+ by_slot[(kd, nm)].setdefault(f, nid)
760
+ slot_maps.append((fset, by_slot, idx))
761
+ # Rust / C / C++ / Swift: alternative definitions of one symbol (`key` + `key@line` / `key@file:line`) on different targets
762
+ alts = _alt_groups(builder, nvals)
763
+ alt_of = {}
764
+ for base, ids in alts.items():
765
+ if len(ids) < 2:
766
+ continue
767
+ sets = {tuple(sorted(p for p, v in nvals.get(i, {p: True for p in KNOWN}).items() if v is not False)) for i in ids}
768
+ if len(sets) < 2:
769
+ continue # same targets: overloads / macro repeats, not platform variants
770
+ for i in ids:
771
+ alt_of[i] = ids
772
+ for k, e in list(builder.edges.items()):
773
+ if e.kind in ("CONTAINS", "DEFINES", "GATED_BY"):
774
+ continue
775
+ for fset, by_slot, idx in slot_maps:
776
+ hit = idx.get(e.dst)
777
+ if hit is None:
778
+ continue
779
+ sn = builder.nodes.get(e.src)
780
+ if sn is not None and sn.file in fset:
781
+ continue
782
+ s, f = hit
783
+ if (e.file, f) in explicit:
784
+ continue # the importer names this platform file: no sibling is reached
785
+ for f2, nid in by_slot[s].items():
786
+ if f2 != f:
787
+ adds.append((e, nid))
788
+ if e.dst in alt_of and e.src not in alt_of[e.dst]:
789
+ for nid in alt_of[e.dst]:
790
+ if nid != e.dst:
791
+ adds.append((e, nid))
792
+ n0 = len(builder.edges)
793
+ for e, nid in adds:
794
+ builder.add_edge(e.src, nid, e.kind, e.file, e.line, e.confidence, **{**e.attrs, "platform_variant_of": e.dst})
795
+ return len(builder.edges) - n0
796
+
797
+
798
+ def _write_attrs(builder, ntag, nvals, etag, targets=None) -> None:
799
+ """platforms / platform_unknown attrs: the project's targets the code exists on (every known target when the
800
+ project declares none), so `#else` / `!os(iOS)` code lists the other targets, not every platform cg knows (#74)."""
801
+ shown = [p for p in KNOWN if p in (targets or KNOWN)]
802
+
803
+ def put(attrs: dict, ms: list, vals: dict):
804
+ attrs["platforms"] = [p for p in shown if vals[p] is not False]
805
+ other = [p for p in KNOWN if p not in shown and vals[p] is not False]
806
+ if other: # known platforms outside the targets the code would exist on: for `--platform <other>`
807
+ attrs["platforms_other"] = other
808
+ unk = [p for p in shown if vals[p] is None]
809
+ if unk:
810
+ attrs["platform_unknown"] = unk
811
+ texts = list(dict.fromkeys(m["cond"].text for m in ms))
812
+ attrs["platform_expr"] = " && ".join(texts)[:240]
813
+ attrs["platform_at"] = f"{ms[0]['file']}:{ms[0]['line']}"
814
+ for nid, ms in ntag.items():
815
+ n = builder.nodes.get(nid)
816
+ if n is not None:
817
+ if n.attrs is None:
818
+ n.attrs = {}
819
+ put(n.attrs, ms, nvals[nid])
820
+ for k, ms in etag.items():
821
+ e = builder.edges.get(k)
822
+ if e is not None:
823
+ e.attrs = dict(e.attrs or {})
824
+ put(e.attrs, ms, _combine([m["cond"].values() for m in ms]))
825
+
826
+
827
+ def _variant_platforms(builder, nvals: dict, targets) -> int:
828
+ """A reference into one of a symbol's per-platform definitions (Rust / C / C++ / Swift `#if` branches) carries
829
+ `variant_platforms`: the targets that definition is built for. Every definition is linked (_mirror), so a caller
830
+ with no condition of its own reaches each variant, labelled with its platforms (#74)."""
831
+ shown = [p for p in KNOWN if p in (targets or KNOWN)]
832
+ alt_of = {}
833
+ for ids in _alt_groups(builder, nvals).values():
834
+ if len(ids) > 1 and any(i in nvals for i in ids):
835
+ for i in ids:
836
+ alt_of[i] = ids
837
+ n = 0
838
+ for e in builder.edges.values():
839
+ if e.dst in alt_of and e.kind not in ("CONTAINS", "DEFINES", "GATED_BY") and e.src not in alt_of[e.dst]:
840
+ e.attrs = {**(e.attrs or {}), "variant_platforms": [p for p in shown if _avail(nvals.get(e.dst), p)]}
841
+ n += 1
842
+ return n
843
+
844
+
845
+ CODE = ("function", "method", "class", "struct", "enum", "trait", "union", "typedef", "type_alias", "component",
846
+ "composable", "store", "macro")
847
+
848
+
849
+ def _per_target(builder, nodes_by_file, nvals, targets) -> dict:
850
+ out = {}
851
+ files = {f for f, ns in nodes_by_file.items() if any(n.kind in CODE for n in ns)}
852
+ for p in targets:
853
+ syms = sum(1 for n in builder.nodes.values() if n.kind in CODE and nvals.get(n.id, {}).get(p, True) is not False)
854
+ only = sum(1 for nid, v in nvals.items() if v.get(p) is not False and builder.nodes.get(nid) is not None
855
+ and builder.nodes[nid].kind in CODE)
856
+ fl = sum(1 for f in files if not all(nvals.get(n.id, {}).get(p, True) is False for n in nodes_by_file[f]
857
+ if n.kind in CODE))
858
+ out[p] = {"files": fl, "symbols": syms, "platform_specific_symbols": only}
859
+ return out
860
+
861
+
862
+ def _avail(v: dict | None, p: str) -> bool:
863
+ return v is None or v.get(p) is not False
864
+
865
+
866
+ def _vkey(nid: str, n, nvals: dict | None = None) -> str:
867
+ """Variant key of a Rust / C / C++ definition: its id without the `@file:line` suffix. In C / C++ a function
868
+ and a function-like macro of the same name are variants too (`#ifdef _WIN32 #define f(h) ... #else` + `void f`).
869
+ In Rust, items of sibling modules gated per platform are variants (`#[cfg(unix)] mod unix; #[cfg(windows)] mod
870
+ windows;` each with `fn new`, re-exported by the parent): the gated module segment is replaced by `*`."""
871
+ b = nid.split("@", 1)[0]
872
+ if n is None:
873
+ return b
874
+ if n.lang in ("c", "cpp") and n.kind in ("function", "macro") and n.name:
875
+ # `file#name`: a static, file-local function (a non-static one defined in several files is one symbol)
876
+ return f"c-fn:{n.file}#{n.name}" if "#" in b and (n.attrs or {}).get("static") else f"c-fn:{n.name}"
877
+ if n.lang == "rust" and nvals and ":" in b:
878
+ kind, path = b.split(":", 1)
879
+ parts = path.split("::")
880
+ for i in range(len(parts) - 1, 1, -1):
881
+ if f"mod:{'::'.join(parts[:i])}" in nvals:
882
+ return f"{kind}:{'::'.join(parts[:i - 1])}::*::{'::'.join(parts[i:])}"
883
+ return b
884
+
885
+
886
+ ALT_KINDS = ("function", "method", "struct", "enum", "type_alias", "typedef", "class", "union", "macro", "const",
887
+ "static", "global", "enum_case", "constant")
888
+
889
+
890
+ def _swift_inherited(builder, nvals: dict, alts: dict, vkey_of: dict):
891
+ """Swift: the targets where a member exists without a definition of its own in the graph (#86). A type defined
892
+ per `#if` branch (`class Popover: NSObject { var isShown }` / `class Popover: NSPopover { }`) has the member on
893
+ the other branch too when that variant conforms to a protocol requiring it, and an `init` when the variant's
894
+ SDK superclass gives it (no initializer of its own on that target)."""
895
+ parent, conforms, names, inits = {}, defaultdict(set), defaultdict(set), defaultdict(list)
896
+ for e in builder.edges.values():
897
+ if e.kind == "CONTAINS" and e.src.startswith("class:"):
898
+ sn = builder.nodes.get(e.src)
899
+ dn = builder.nodes.get(e.dst)
900
+ if sn is None or dn is None or sn.lang != "swift":
901
+ continue
902
+ parent[e.dst] = e.src
903
+ names[e.src].add(dn.name)
904
+ if dn.name == "init":
905
+ inits[e.src].append(e.dst)
906
+ elif e.kind == "IMPLEMENTS" and e.src.startswith("class:"):
907
+ conforms[e.src].add(e.dst)
908
+ for nid, n in builder.nodes.items():
909
+ if n.lang == "swift" and n.kind == "class":
910
+ names[nid] |= set((n.attrs or {}).get("property_requirements") or ())
911
+
912
+ def avail(i):
913
+ return {p for p in KNOWN if _avail(nvals.get(i), p)}
914
+
915
+ def of(dst: str) -> set:
916
+ dn = builder.nodes.get(dst)
917
+ c = parent.get(dst)
918
+ if dn is None or c is None:
919
+ return set()
920
+ have = set()
921
+ for v in alts.get(vkey_of.get(c, c)) or [c]:
922
+ vn = builder.nodes.get(v)
923
+ if vn is None:
924
+ continue
925
+ if any(dn.name in names.get(pr, ()) for pr in conforms.get(v, ())):
926
+ have |= avail(v)
927
+ elif dn.name == "init" and (vn.attrs or {}).get("external_supers"):
928
+ own = set().union(*[avail(i) for i in inits.get(v, ())]) if inits.get(v) else set()
929
+ have |= avail(v) - own
930
+ return have
931
+ return of
932
+
933
+
934
+ def _alt_groups(builder, nvals: dict) -> dict[str, list[str]]:
935
+ """Rust / C / C++ / Swift alternative definitions of one symbol (one per `cfg` / `#if` branch), keyed by _vkey."""
936
+ alts: dict[str, list[str]] = defaultdict(list)
937
+ for nid, n in builder.nodes.items():
938
+ if n.lang in ("rust", "c", "cpp", "swift") and n.kind in ALT_KINDS:
939
+ alts[_vkey(nid, n, nvals)].append(nid)
940
+ # a static C function and a macro / function of the same name visible in its file are variants too
941
+ for k in [k for k in alts if k.startswith("c-fn:") and "#" in k]:
942
+ file, name = k[5:].rsplit("#", 1)
943
+ g = f"c-fn:{name}"
944
+ if g in alts and any(builder.nodes[i].file == file or builder.nodes[i].kind == "macro" for i in alts[g]):
945
+ alts[g] += alts.pop(k)
946
+ # same-named functions of separate programs (libuv docs/code/*/main.c `alloc_buffer`, each file with its own
947
+ # main()) are not one symbol: members in two or more files that define main() leave the group (#63)
948
+ mains = {n.file for n in builder.nodes.values() if n.lang in ("c", "cpp") and n.kind == "function" and n.name == "main"}
949
+ if mains:
950
+ for k, ids in list(alts.items()):
951
+ if not k.startswith("c-fn:"):
952
+ continue
953
+ prog = [i for i in ids if builder.nodes[i].file in mains]
954
+ if len({builder.nodes[i].file for i in prog}) >= 2:
955
+ rest = [i for i in ids if i not in prog]
956
+ if rest:
957
+ alts[k] = rest
958
+ else:
959
+ del alts[k]
960
+ return alts
961
+
962
+
963
+ API_KINDS = ("function", "method", "class", "component", "composable", "store")
964
+
965
+
966
+ def _public(n, group_files: set, ext_refs: dict, users: set) -> bool:
967
+ """Part of the API the importers of a variant group rely on: used by a file that imports the group, or, when the
968
+ group has no importer in the graph, exported (TS) / public (Dart) / used from outside the group."""
969
+ refs = ext_refs.get(n.id, set())
970
+ if users:
971
+ return bool(refs & users)
972
+ if (n.attrs or {}).get("exported"):
973
+ return True
974
+ if n.lang == "dart" and not _local_name(n).startswith("_"):
975
+ return True
976
+ return bool(refs - group_files)
977
+
978
+
979
+ def divergence_findings(builder, groups: list[dict], nvals: dict, targets: list[str], limit: int = 400) -> dict:
980
+ """Platform divergence for the declared targets:
981
+ variants a symbol / module implemented per platform (variant groups) and the targets none covers
982
+ api_surface symbols one variant file defines and a sibling variant does not
983
+ missing_callee references live on a target where the referenced code does not exist there (and no variant does)
984
+ """
985
+ tset = [p for p in KNOWN if p in targets]
986
+ shown = tset or list(KNOWN) # platforms listed per member: the project's targets (#74)
987
+ out = {"variants": [], "api_surface": [], "missing_callee": [], "missing_callee_tests": []}
988
+ explicit = getattr(builder, "explicit_variant_imports", None) or set()
989
+ importers = defaultdict(set)
990
+ ext_refs = defaultdict(set) # node -> files referring to it
991
+ for e in builder.edges.values():
992
+ if e.kind in ("IMPORTS", "CALLS", "REFERENCES_FN") and e.file:
993
+ importers[e.dst].add(f"{e.file}:{e.line}")
994
+ if e.kind not in ("CONTAINS", "DEFINES") and e.file:
995
+ ext_refs[e.dst].add(e.file)
996
+
997
+ def importers_of(g) -> set:
998
+ return {u for f, _ in g["members"] for u in importers.get(f"module:{f}", ())}
999
+ for g in groups:
1000
+ covered = set()
1001
+ mem = []
1002
+ for f, c in g["members"]:
1003
+ ps = [p for p, v in c.values().items() if v is not False]
1004
+ covered |= set(ps)
1005
+ mem.append({"file": f, "platforms": [p for p in ps if p in shown], "condition": c.text})
1006
+ used = sorted({u for f, _ in g["members"] for u in importers.get(f"module:{f}", ())
1007
+ if not any(u.startswith(m + ":") for m, _ in g["members"])})
1008
+ missing = [p for p in tset if p not in covered]
1009
+ out["variants"].append({"kind": g["kind"], "name": g["name"], "members": mem, "covered": [p for p in KNOWN if p in covered and p in tset],
1010
+ "missing": missing, "used_at": used[:5], **({"at": g["at"]} if g.get("at") else {})})
1011
+ # API surface: public symbols a member defines that a sibling lacks (exported TS symbols, public Dart names,
1012
+ # anything referenced from outside the group)
1013
+ files = {f for f, _ in g["members"]}
1014
+ users = {u.rsplit(":", 1)[0] for u in importers_of(g)} - files # files that import the group
1015
+ defs, anyname = {}, {}
1016
+ for f, c in g["members"]:
1017
+ defs[f] = {(_slot(n, f) or ("?", n.id))[1]: n for n in builder.nodes.values() if n.file == f
1018
+ and n.kind in API_KINDS and _slot(n, f)
1019
+ and _public(n, files, ext_refs, users)}
1020
+ # every name the file defines in any form (also private / non-function symbols and re-exports): a
1021
+ # sibling lacks a symbol only when it defines nothing of that name
1022
+ anyname[f] = {sl[1] for n in builder.nodes.values() if n.file == f for sl in [_slot(n, f)] if sl and sl[1]}
1023
+ mn = builder.nodes.get(f"module:{f}")
1024
+ anyname[f] |= set(((mn.attrs or {}) if mn is not None else {}).get("reexports_external") or ())
1025
+ for nm, nid in _reexported(builder, f).items():
1026
+ anyname[f].add(nm)
1027
+ tn = builder.nodes[nid]
1028
+ if tn.kind in API_KINDS and (not users or ext_refs.get(nid, set()) & users):
1029
+ defs[f].setdefault(nm, tn)
1030
+ allnames = set().union(*[set(d) for d in defs.values()]) if defs else set()
1031
+ for nm in sorted(allnames):
1032
+ have = [f for f in defs if nm in defs[f]]
1033
+ lack = [f for f in defs if nm not in defs[f] and nm not in anyname.get(f, ())]
1034
+ if have and lack and "#" not in nm and "." not in nm:
1035
+ lack_pl = sorted({p for f, c in g["members"] if f in lack for p, v in c.values().items() if v is not False and p in tset})
1036
+ if lack_pl:
1037
+ out["api_surface"].append({"group": g["name"], "symbol": nm, "defined_in": have, "missing_in": lack,
1038
+ "missing_on": [p for p in KNOWN if p in lack_pl]})
1039
+ # id-variant groups (Rust / C): covered targets per symbol
1040
+ alts = {k: ids for k, ids in _alt_groups(builder, nvals).items() if any(i in nvals for i in ids)}
1041
+ vkey_of = {i: k for k, ids in alts.items() for i in ids}
1042
+ for base, ids in sorted(alts.items()):
1043
+ if len(ids) < 2 or not any(builder.nodes[i].kind in ("function", "method", "macro") for i in ids):
1044
+ continue
1045
+ sets = {tuple(p for p in KNOWN if _avail(nvals.get(i), p)) for i in ids}
1046
+ if len(sets) < 2:
1047
+ continue # same targets: overloads / repeated declarations, not platform variants
1048
+ covered = {p for i in ids for p in KNOWN if _avail(nvals.get(i), p)}
1049
+ n0 = builder.nodes[ids[0]]
1050
+ out["variants"].append({"kind": "per-platform definitions", "name": base.split(":", 1)[1] if "::*::" in base else (n0.fqn or n0.name),
1051
+ "members": [{"id": i, "file": builder.nodes[i].file, "line": builder.nodes[i].line,
1052
+ "platforms": [p for p in shown if _avail(nvals.get(i), p)],
1053
+ "condition": builder.nodes[i].attrs.get("platform_expr")} for i in ids],
1054
+ "covered": [p for p in KNOWN if p in covered and p in tset], "missing": [p for p in tset if p not in covered],
1055
+ "used_at": sorted(importers.get(ids[0], ()))[:5]})
1056
+ # references on a target where the callee does not exist (a reference to any variant counts for the group)
1057
+ gslot = {}
1058
+ gmembers = {g["name"]: g["members"] for g in groups}
1059
+ for g in groups:
1060
+ files = {f for f, _ in g["members"]}
1061
+ for n in builder.nodes.values():
1062
+ if n.file in files:
1063
+ sl = _slot(n, n.file)
1064
+ if sl is not None:
1065
+ gslot[n.id] = ("group", g["name"], sl)
1066
+ group_of = {f: g["name"] for g in groups for f, _ in g["members"]}
1067
+ inherited = _swift_inherited(builder, nvals, alts, vkey_of)
1068
+ sites = defaultdict(list)
1069
+ for e in builder.edges.values():
1070
+ if e.kind in ("CALLS", "REFERENCES_FN", "USES_TYPE", "USES_VALUE", "IMPORTS", "INSTANTIATES", "RENDERS",
1071
+ "USES_COMPOSABLE", "USES_STORE") and (e.dst in nvals or e.attrs.get("platform_variant_of") in nvals):
1072
+ if e.kind == "IMPORTS" and e.file in group_of:
1073
+ dn = builder.nodes.get(e.dst)
1074
+ if dn is not None and dn.lang != "dart" and group_of.get(dn.file) == group_of[e.file]:
1075
+ # a platform file naming its sibling explicitly (`export * from './X.ios'`): bundled as written
1076
+ continue
1077
+ if e.kind == "CALLS" and e.attrs.get("binding") in ("name", "candidate"):
1078
+ # a call bound by its selector alone (receiver type unknown, Swift / Kotlin #83): too weak as well
1079
+ continue
1080
+ if e.kind == "USES_VALUE" and (e.confidence == "heuristic" or e.dst.startswith("macro:")):
1081
+ # a global matched by name only, or a macro tested by #ifdef: too weak to call a target broken
1082
+ continue
1083
+ base = e.attrs.get("platform_variant_of") or e.dst
1084
+ dn = builder.nodes.get(base)
1085
+ if dn is not None and (e.file, dn.file) in explicit:
1086
+ continue # `from './X.ios'`: that file is bundled wherever the importer is
1087
+ if dn is not None and dn.lang == "swift" and dn.name == "init" and e.confidence == "heuristic":
1088
+ cn = builder.nodes.get("class:" + (dn.fqn or "").rpartition(".")[0])
1089
+ if cn is not None and (cn.attrs or {}).get("swift_kind") == "extension":
1090
+ # an initializer the project adds to an SDK type (`extension Image { init(systemName:) }` under
1091
+ # `#if os(macOS)`): elsewhere the call uses the SDK's own initializer (#63)
1092
+ continue
1093
+ if base in gslot:
1094
+ base = gslot[base]
1095
+ elif dn is not None and dn.lang in ("rust", "c", "cpp", "swift"):
1096
+ base = vkey_of.get(base, base) # Swift: `func close()` per `#if` branch (#74)
1097
+ sites[(e.src, e.file, e.line, e.kind, base)].append(e)
1098
+ # every module one import line can resolve to (a conditional import names several on one line)
1099
+ import_alts = defaultdict(set)
1100
+ outside_default = set() # conditional import whose default is outside the index (e.g. `dart:isolate`)
1101
+ for e in builder.edges.values():
1102
+ if e.kind == "IMPORTS":
1103
+ import_alts[(e.src, e.file, e.line)].add(e.dst)
1104
+ if e.attrs.get("conditional"):
1105
+ outside_default.add((e.src, e.file, e.line))
1106
+ for e in builder.edges.values():
1107
+ if e.kind == "IMPORTS" and e.attrs.get("conditional") and not e.attrs.get("condition"):
1108
+ outside_default.discard((e.src, e.file, e.line))
1109
+ skipped_no_target = 0
1110
+ for (src, f, line, kind, base), es in sites.items():
1111
+ sv = nvals.get(src)
1112
+ ev = es[0].attrs
1113
+ live = [p for p in tset if _avail(sv, p) and ("platforms" not in ev or p in ev["platforms"])]
1114
+ have = {p for e in es for p in KNOWN if _avail(nvals.get(e.dst), p)}
1115
+ if kind == "IMPORTS":
1116
+ have |= {p for d in import_alts.get((src, f, line), ()) for p in KNOWN if _avail(nvals.get(d), p)}
1117
+ if (src, f, line) in outside_default:
1118
+ have |= set(KNOWN)
1119
+ if isinstance(base, str) and base in alts: # any definition of the symbol on that target will do
1120
+ have |= {p for i in alts[base] for p in KNOWN if _avail(nvals.get(i), p)}
1121
+ if not set(live) <= have:
1122
+ have |= inherited(es[0].attrs.get("platform_variant_of") or es[0].dst)
1123
+ if isinstance(base, tuple) and base[0] == "group" and base[2][1]:
1124
+ # a member that re-exports the name from a package (`export {x} from 'react-dom'`, `export const X = Y`)
1125
+ for mf, c in gmembers.get(base[1], ()):
1126
+ mn = builder.nodes.get(f"module:{mf}")
1127
+ if mn is not None and base[2][1] in ((mn.attrs or {}).get("reexports_external") or ()):
1128
+ have |= {p for p, v in c.values().items() if v is not False}
1129
+ miss = [p for p in live if p not in have]
1130
+ if miss and not have:
1131
+ # the callee exists on no known target (sunos.c `strnlen`, aix.c helpers): the targets' builds use a system
1132
+ # or another definition the index does not see; that is no finding about them (#63)
1133
+ skipped_no_target += 1
1134
+ continue
1135
+ if miss:
1136
+ dst = es[0].attrs.get("platform_variant_of") or es[0].dst
1137
+ item = {"from": src, "to": dst, "kind": kind, "at": f"{f}:{line}", "missing_on": miss,
1138
+ "callee_platforms": [p for p in shown if p in have],
1139
+ "callee_condition": " | ".join(dict.fromkeys(
1140
+ (builder.nodes[e.dst].attrs or {}).get("platform_expr") or "" for e in es
1141
+ if e.dst in builder.nodes)) or None}
1142
+ # test code whose platforms are only the project-wide default (no #if, no target membership narrowing
1143
+ # them): an unguarded call into code excluded on a platform means that test target is not built there,
1144
+ # so it is listed apart (#91)
1145
+ if _test_code(builder.nodes.get(src), f) and not any(v is False for v in (sv or {}).values()):
1146
+ item["platform_source"] = "project default (test target)"
1147
+ out["missing_callee_tests"].append(item)
1148
+ else:
1149
+ out["missing_callee"].append(item)
1150
+ out["variants"] = [v for v in out["variants"]]
1151
+ out["counts"] = {k: len(v) for k, v in out.items()}
1152
+ if skipped_no_target:
1153
+ out["counts"]["missing_callee_skipped_no_target"] = skipped_no_target
1154
+ if not out["missing_callee_tests"]:
1155
+ del out["missing_callee_tests"], out["counts"]["missing_callee_tests"]
1156
+ for k in ("variants", "api_surface", "missing_callee", "missing_callee_tests"):
1157
+ if k in out:
1158
+ out[k] = out[k][:limit]
1159
+ return out
1160
+
1161
+
1162
+ TEST_FILE_RE = re.compile(r"(?:^|/)(?:Tests?|tests?|__tests__|spec|androidTest|\w*Test|\w*Tests)/|"
1163
+ r"(?:Tests?|_test|\.test|\.spec|Spec)\.\w+$")
1164
+
1165
+
1166
+ def _test_code(n, f: str) -> bool:
1167
+ if n is not None and (n.kind == "test" or (n.attrs or {}).get("test")):
1168
+ return True
1169
+ return bool(f and TEST_FILE_RE.search(f))
1170
+
1171
+
1172
+ # ------------------------------------------------------------------ queries
1173
+ def has_platforms(st) -> bool:
1174
+ return bool((st.meta().get("stats") or {}).get("platforms"))
1175
+
1176
+
1177
+ def summary(st) -> dict:
1178
+ return (st.meta().get("stats") or {}).get("platforms") or {}
1179
+
1180
+
1181
+ def exclusions(st, platform: str) -> dict:
1182
+ """Nodes and edges that do not exist on `platform` (their condition is false there), and how many conditions
1183
+ could not be evaluated for it (kept live). Cached per store; also fills temp.t_px with the excluded edge ids."""
1184
+ cache = getattr(st, "_platform_x", None)
1185
+ if cache is None:
1186
+ cache = st._platform_x = {}
1187
+ if platform in cache:
1188
+ return cache[platform]
1189
+ nodes, unknown_n = set(), 0
1190
+ for r in st.q("SELECT id, attrs FROM nodes WHERE attrs LIKE '%\"platforms\"%'"):
1191
+ a = json.loads(r["attrs"] or "{}")
1192
+ if "platforms" in a:
1193
+ if platform not in a["platforms"] and platform not in (a.get("platforms_other") or ()):
1194
+ nodes.add(r["id"])
1195
+ elif platform in (a.get("platform_unknown") or ()):
1196
+ unknown_n += 1
1197
+ edges, unknown_e = set(), 0
1198
+ for r in st.q("SELECT id, attrs FROM edges WHERE attrs LIKE '%\"platforms\"%'"):
1199
+ a = json.loads(r["attrs"] or "{}")
1200
+ if "platforms" in a:
1201
+ if platform not in a["platforms"] and platform not in (a.get("platforms_other") or ()):
1202
+ edges.add(r["id"])
1203
+ elif platform in (a.get("platform_unknown") or ()):
1204
+ unknown_e += 1
1205
+ if nodes:
1206
+ st.db.execute("DROP TABLE IF EXISTS temp.t_pxn")
1207
+ st.db.execute("CREATE TEMP TABLE t_pxn(id TEXT PRIMARY KEY)")
1208
+ st.db.executemany("INSERT OR IGNORE INTO t_pxn VALUES (?)", [(x,) for x in nodes])
1209
+ edges |= {r["id"] for r in st.q("SELECT e.id FROM edges e JOIN t_pxn n ON n.id = e.src")}
1210
+ edges |= {r["id"] for r in st.q("SELECT e.id FROM edges e JOIN t_pxn n ON n.id = e.dst")}
1211
+ res = {"platform": platform, "nodes": nodes, "edges": edges, "unknown": unknown_n + unknown_e}
1212
+ cache[platform] = res
1213
+ return res
1214
+
1215
+
1216
+ def temp_table(st, platform: str) -> str:
1217
+ """Name of a temp table holding the edge ids excluded on `platform` (for SQL traversals)."""
1218
+ x = exclusions(st, platform)
1219
+ name = f"t_px_{platform}"
1220
+ if not getattr(st, "_platform_tbl", None):
1221
+ st._platform_tbl = set()
1222
+ if name not in st._platform_tbl:
1223
+ st.db.execute(f"DROP TABLE IF EXISTS temp.{name}")
1224
+ st.db.execute(f"CREATE TEMP TABLE {name}(id INTEGER PRIMARY KEY)")
1225
+ st.db.executemany(f"INSERT OR IGNORE INTO {name} VALUES (?)", [(i,) for i in x["edges"]])
1226
+ st._platform_tbl.add(name)
1227
+ return name
1228
+
1229
+
1230
+ def filter_info(st, platform: str | None) -> dict | None:
1231
+ """What a --platform filter did, for the reply: the target, excluded nodes / edges, unevaluated conditions."""
1232
+ if not platform:
1233
+ return None
1234
+ x = exclusions(st, platform)
1235
+ s = summary(st)
1236
+ upt = s.get("unevaluated_per_target") or {}
1237
+ info = {"platform": platform, "excluded_nodes": len(x["nodes"]), "excluded_edges": len(x["edges"]),
1238
+ # conditions per declared target; for another target, the tagged code whose condition is unknown there
1239
+ "unevaluated_conditions": upt[platform] if platform in upt else x["unknown"],
1240
+ "kept_unevaluated": x["unknown"]}
1241
+ if not s:
1242
+ info["note"] = "this graph has no platform-specific code: the filter changes nothing"
1243
+ elif s.get("targets") and platform not in s["targets"]:
1244
+ info["note"] = f"{platform} is not a declared target of this project ({', '.join(s['targets'])})"
1245
+ return info
1246
+
1247
+
1248
+ def render_filter(info: dict | None) -> str:
1249
+ if not info:
1250
+ return ""
1251
+ s = (f"platform: {info['platform']} ({info['excluded_nodes']} nodes and {info['excluded_edges']} references not built "
1252
+ f"for it left out; {info['unevaluated_conditions']} conditions could not be evaluated for it, the code under "
1253
+ f"them stays in)")
1254
+ return s + (f"; {info['note']}" if info.get("note") else "")
1255
+
1256
+
1257
+ def node_platforms(attrs_json: str | None) -> dict:
1258
+ if not attrs_json or ('"platforms"' not in attrs_json and '"available"' not in attrs_json):
1259
+ return {}
1260
+ try:
1261
+ a = json.loads(attrs_json) or {}
1262
+ except ValueError:
1263
+ return {}
1264
+ out = {}
1265
+ if isinstance(a.get("available"), dict):
1266
+ out["available"] = a["available"]
1267
+ if "platforms" in a:
1268
+ out["platforms"] = a["platforms"]
1269
+ if a.get("platform_unknown"):
1270
+ out["platform_unknown"] = a["platform_unknown"]
1271
+ if a.get("platform_expr"):
1272
+ out["platform_expr"] = a["platform_expr"]
1273
+ return out
1274
+
1275
+
1276
+ def label(n: dict) -> str:
1277
+ """' [ios, android]' for platform-specific code, '' for code on every target."""
1278
+ ps = n.get("platforms")
1279
+ av = n.get("available") if isinstance(n.get("available"), dict) else None
1280
+ out = ""
1281
+ if ps is not None:
1282
+ unk = n.get("platform_unknown") or []
1283
+ out = " [" + (", ".join(p + ("?" if p in unk else "") for p in ps) or "no known target") + "]"
1284
+ if av: # Swift @available minimum OS versions
1285
+ out += " [" + ", ".join(f"{k} {v}+" for k, v in av.items()) + "]"
1286
+ return out
1287
+
1288
+
1289
+ def divergence(st, kind: str | None = None, target: str | None = None) -> dict:
1290
+ s = summary(st)
1291
+ d = dict(s.get("divergence") or {})
1292
+ if target:
1293
+ d["variants"] = [v for v in d.get("variants", []) if target in v.get("missing", [])]
1294
+ d["api_surface"] = [v for v in d.get("api_surface", []) if target in v.get("missing_on", [])]
1295
+ d["missing_callee"] = [v for v in d.get("missing_callee", []) if target in v.get("missing_on", [])]
1296
+ if "missing_callee_tests" in d:
1297
+ d["missing_callee_tests"] = [v for v in d["missing_callee_tests"] if target in v.get("missing_on", [])]
1298
+ if kind:
1299
+ d = {k: (v if k == kind else []) for k, v in d.items() if k != "counts"} | {"counts": d.get("counts")}
1300
+ return {"targets": s.get("targets") or [], "target_sources": s.get("target_sources") or {}, **d}
1301
+
1302
+
1303
+ def render_divergence(res: dict, limit: int = 40) -> str:
1304
+ if not res.get("targets") and not res.get("variants"):
1305
+ return "no platform-specific code in this graph (no platform conditions, platform files or conditional imports)"
1306
+ out = [f"targets: {', '.join(res['targets']) or '(none declared)'}"
1307
+ + (f" ({'; '.join(f'{k}: {v}' for k, v in list(res.get('target_sources', {}).items())[:4])})" if res.get("target_sources") else "")]
1308
+ var = res.get("variants") or []
1309
+ gaps = [v for v in var if v.get("missing")]
1310
+ out.append(f"\n== VARIANTS (implemented per platform): {len(var)}, {len(gaps)} with a declared target no variant covers")
1311
+ for v in sorted(var, key=lambda v: (not v.get("missing"), v["name"]))[:limit]:
1312
+ miss = f" missing: {', '.join(v['missing'])}" if v.get("missing") else ""
1313
+ out.append(f" {v['name']} [{v['kind']}] covered: {', '.join(v['covered']) or '-'}{miss}")
1314
+ for m in v["members"][:6]:
1315
+ loc = m.get("file", "") + (f":{m['line']}" if m.get("line") else "")
1316
+ out.append(f" {loc} {', '.join(m['platforms']) or 'no known target'} ({m.get('condition') or '-'})")
1317
+ if v.get("used_at"):
1318
+ out.append(f" used at: {', '.join(v['used_at'][:3])}")
1319
+ api = res.get("api_surface") or []
1320
+ if api:
1321
+ out.append(f"\n== API SURFACE DIFFERS (a variant lacks a symbol its siblings define): {len(api)}")
1322
+ for a in api[:limit]:
1323
+ out.append(f" {a['symbol']} in {', '.join(a['defined_in'])}; missing in {', '.join(a['missing_in'])} "
1324
+ f"(targets {', '.join(a['missing_on'])})")
1325
+ mc = res.get("missing_callee") or []
1326
+ if mc:
1327
+ out.append(f"\n== REFERENCED WHERE THE CALLEE IS NOT BUILT: {len(mc)}")
1328
+ for m in mc[:limit]:
1329
+ out.append(f" {m['from']} -{m['kind']}-> {m['to']} @ {m['at']} missing on: {', '.join(m['missing_on'])}"
1330
+ f" (callee: {', '.join(m['callee_platforms']) or 'no known target'}"
1331
+ + (f", {m['callee_condition']}" if m.get("callee_condition") else "") + ")")
1332
+ mt = res.get("missing_callee_tests") or []
1333
+ if mt:
1334
+ out.append(f"\n== FROM TEST CODE WHOSE PLATFORMS ARE THE PROJECT DEFAULT: {len(mt)} (the test target is "
1335
+ f"probably not built there; `--kind missing_callee_tests`)")
1336
+ for m in mt[:limit]:
1337
+ out.append(f" {m['from']} -{m['kind']}-> {m['to']} @ {m['at']} missing on: {', '.join(m['missing_on'])}"
1338
+ f" (callee: {', '.join(m['callee_platforms']) or 'no known target'}"
1339
+ + (f", {m['callee_condition']}" if m.get("callee_condition") else "") + ")")
1340
+ c = res.get("counts") or {}
1341
+ if c.get("missing_callee_skipped_no_target"):
1342
+ out.append(f" ({c['missing_callee_skipped_no_target']} reference(s) to code built for no declared target not listed:"
1343
+ f" the targets use a system or unindexed definition)")
1344
+ if any(c.get(k, 0) > len(res.get(k) or []) for k in ("variants", "api_surface", "missing_callee")):
1345
+ out.append(f"\n(lists capped; totals: {', '.join(f'{k} {v}' for k, v in c.items())})")
1346
+ return "\n".join(out)
1347
+
1348
+
1349
+ def render_summary(s: dict) -> str:
1350
+ if not s:
1351
+ return "no platform-specific code in this graph"
1352
+ out = [f"targets: {', '.join(s.get('targets') or []) or '(none)'}"]
1353
+ for p, src in (s.get("target_sources") or {}).items():
1354
+ out.append(f" {p}: {src}")
1355
+ out.append(f"conditions: {s['conditions']} in {s['files_with_conditions']} files; tagged {s['tagged_nodes']} symbols and "
1356
+ f"{s['tagged_edges']} references; {s.get('variant_edges', 0)} variant links")
1357
+ pt = s.get("per_target") or {}
1358
+ for p in s.get("targets") or []:
1359
+ if p in pt:
1360
+ x = pt[p]
1361
+ out.append(f" {p:8} {x['files']} files, {x['symbols']} symbols ({x['platform_specific_symbols']} platform-specific)")
1362
+ if s.get("unevaluated_conditions"):
1363
+ out.append(f"not evaluated: {s['unevaluated_conditions']} conditions (kept live), e.g. "
1364
+ + "; ".join(s.get("unevaluated_samples") or [])[:300])
1365
+ d = (s.get("divergence") or {}).get("counts") or {}
1366
+ if d:
1367
+ out.append(f"divergence: {d.get('variants', 0)} variant groups, {d.get('api_surface', 0)} API differences, "
1368
+ f"{d.get('missing_callee', 0)} references to code missing on a target (cg platforms divergence)")
1369
+ return "\n".join(out)