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/parity.py ADDED
@@ -0,0 +1,473 @@
1
+ """`cg parity` (#85): symbols of a source graph with no counterpart in a target graph, for an app ported between
2
+ platforms (Swift / SwiftUI <-> Kotlin / Compose, or any two indexed languages). See docs/parity.md.
3
+
4
+ Compared: types (class / struct / enum / protocol / interface / object), the members of matched types (methods,
5
+ properties, enum cases, constants, Kotlin nested sealed-class objects), and top-level functions / constants. Test
6
+ code, test support (`TestHelpers/`, `Mock*`), Swift `Has*` service-locator protocols, extension nodes, Kotlin
7
+ companion objects, lifecycle overrides and boilerplate (`body`, `init`, `hash`...) are left out.
8
+ A source symbol is matched by, in this order:
9
+ explicit a comment above the declaration naming its counterpart (`/// Port of: FooView`, `// iOS: Foo.bar`,
10
+ `// Android: FooScreen`) on either side, or the `--map` JSON file {"source name": "target name"}
11
+ exact the same name in the same category (type / function / constant); members: same name in the matched type
12
+ normalized case and `_` folded; `Default` / `Impl` / `Json` / `RequestModel` and `--strip-prefix` words dropped;
13
+ `VM` and `Processor` read as ViewModel; UI suffixes (View, Screen, Sheet, Page, Fragment, Activity,
14
+ ViewController, VC, Controller) stripped on both sides only (a model `Otp` does not meet `OtpFragment`);
15
+ the same words in another order; a SwiftUI type with `body` also matches a `@Composable` function.
16
+ Members: `get` prefixes and UI event verbs (`deletePressed` / `DeleteClick`) dropped
17
+ fuzzy the same words with one of them shortened or pluralised (`ItemList` / `ItemListing`)
18
+ moved a member missing on the counterpart type whose name (three words or more) exists on exactly one other
19
+ target type
20
+ A nested type is looked for inside its owner's counterpart (elsewhere by exact name only). Symbols in a file that
21
+ parsed with syntax errors go to `unknown`, not `missing`; symbols tagged only for platforms the target does not build
22
+ (`attrs.platforms`) go to `platform_only`."""
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ import sqlite3
28
+ from collections import defaultdict
29
+ from pathlib import Path
30
+
31
+ TYPE_KINDS = {"class", "struct", "enum", "protocol", "interface", "object", "actor", "trait"}
32
+ UI_SUFFIXES = ("viewcontroller", "controller", "fragment", "activity", "screen", "sheet", "page", "view", "vc")
33
+ EXPLICIT = re.compile(r"(?:port(?:ed)? of|counterpart|ios|android|swift|kotlin)\s*:\s*`?([A-Za-z_][\w.]*)", re.I)
34
+
35
+
36
+ # test support and dependency-injection glue: no counterpart expected on the other platform
37
+ SUPPORT_PATH = re.compile(r"(^|/)(\w*TestHelpers?|Mocks?|Fakes?|Stubs?|Fixtures?|testFixtures|PreviewContent)(/|$)")
38
+ SUPPORT_NAME = re.compile(r"^(Mock|Fake|Stub|Spy)[A-Z]")
39
+ DI_PROTOCOL = re.compile(r"^Has[A-Z]\w*$") # Swift `protocol HasAuthService` (service-locator composition)
40
+
41
+
42
+ _PREFIXES: list[str] = [] # --strip-prefix words (lower case), set for one parity() run
43
+
44
+
45
+ def _fold(name: str) -> str:
46
+ s = re.sub(r"[_\s`]", "", name or "").lower()
47
+ for p in _PREFIXES:
48
+ if s.startswith(p) and len(s) > len(p) + 2:
49
+ s = s[len(p):]
50
+ break
51
+ s = re.sub(r"vm$", "viewmodel", s)
52
+ s = re.sub(r"processor$", "viewmodel", s) # Swift processor / Kotlin view model (unidirectional flow)
53
+ s = re.sub(r"^default(?=[a-z]{3})", "", s) # Swift `DefaultFooService` / Kotlin `FooServiceImpl`
54
+ s = re.sub(r"(?<=[a-z]{3})impl$", "", s)
55
+ s = re.sub(r"(?<=[a-z]{3})json$", "", s) # Kotlin wire models `FooResponseJson`
56
+ s = re.sub(r"(request|response)model$", r"\1", s)
57
+ return s
58
+
59
+
60
+ def norm(name: str) -> str:
61
+ s = _fold(name)
62
+ for suf in UI_SUFFIXES:
63
+ if s.endswith(suf) and len(s) > len(suf):
64
+ return s[: -len(suf)]
65
+ return s
66
+
67
+
68
+ def norm_member(name: str) -> str:
69
+ """Members: case / underscores folded (`case fooBar` == `FOO_BAR`), Swift argument labels and Kotlin `()` gone,
70
+ a `get` accessor prefix (`getDefaultUriMatchType()` / `var defaultUriMatchType`) and the UI event verb
71
+ (`deletePressed` / `deleteTapped` / `DeleteClick`) dropped."""
72
+ n = (name or "").split("(")[0]
73
+ n = re.sub(r"^get(?=[A-Z])", "", n)
74
+ s = re.sub(r"[_\s`]", "", n).lower()
75
+ return re.sub(r"(?<=[a-z]{3})(pressed|tapped|clicked|click|tap)$", "", s)
76
+
77
+
78
+ def _kind_of(r) -> str:
79
+ a = json.loads(r["attrs"] or "{}")
80
+ k = a.get("swift_kind") or a.get("kotlin_kind") or r["kind"]
81
+ return "type" if (r["kind"] == "class" or k in TYPE_KINDS) and k != "extension" else r["kind"]
82
+
83
+
84
+ def load(db: str) -> dict:
85
+ c = sqlite3.connect(db)
86
+ c.row_factory = sqlite3.Row
87
+ meta = dict(c.execute("SELECT key, value FROM meta"))
88
+ st = json.loads(meta.get("stats") or "{}")
89
+ err_files = set()
90
+ for e in ((st.get("coverage") or {}).get("languages") or []):
91
+ for x in e.get("syntax_errors") or []:
92
+ err_files.add(x.get("file"))
93
+ platforms = set(((st.get("platforms") or {}).get("targets")) or [])
94
+ try:
95
+ root = Path(json.loads(meta.get("root") or '"."'))
96
+ except ValueError:
97
+ root = Path(meta.get("root") or ".")
98
+ lines_of: dict = {}
99
+ types, members, top, support = {}, defaultdict(dict), {}, 0
100
+ rows = c.execute("SELECT id, kind, name, fqn, file, line, module, doc, lang, attrs FROM nodes WHERE kind IN "
101
+ "('class','method','function','enum_case','constant')").fetchall()
102
+ for r in rows:
103
+ a = json.loads(r["attrs"] or "{}")
104
+ if a.get("test") or a.get("placeholder") or not r["file"]:
105
+ continue
106
+ fqn = (r["fqn"] or r["name"] or "").replace(".Companion.", ".") # Kotlin companion members -> the class
107
+ if fqn.endswith(".Companion") or fqn == "Companion":
108
+ continue
109
+ sym = {"id": r["id"], "name": r["name"], "fqn": fqn, "file": r["file"], "line": r["line"],
110
+ "kind": r["kind"], "lang": r["lang"], "platforms": a.get("platforms"),
111
+ "composable": "Composable" in (a.get("annotations") or []), "module": r["module"],
112
+ "override": bool(a.get("override")),
113
+ "doc": r["doc"] or _comment_above(root, r["file"], r["line"], lines_of)}
114
+ sk = a.get("swift_kind") or a.get("kotlin_kind")
115
+ if sk == "extension":
116
+ continue # the extension node itself; its members stay with the extended type
117
+ if SUPPORT_PATH.search(r["file"]) or any(SUPPORT_NAME.match(x) for x in fqn.split(".")) or \
118
+ (sk == "protocol" and DI_PROTOCOL.match(r["name"] or "")):
119
+ support += 1
120
+ continue
121
+ if _kind_of(r) == "type":
122
+ types.setdefault(sym["fqn"], sym)
123
+ elif r["kind"] in ("method", "enum_case", "constant") and r["module"] and "." in (sym["fqn"] or ""):
124
+ owner = sym["fqn"].rsplit(".", 1)[0]
125
+ sym["name"] = sym["fqn"].rsplit(".", 1)[1]
126
+ members[owner].setdefault(sym["name"], sym)
127
+ else:
128
+ top.setdefault(sym["fqn"], sym)
129
+ # members of a type nobody declares here (extensions of library types) stay top-level-ish: keyed by owner
130
+ for owner in [o for o in members if o not in types]:
131
+ for nm, sym in members.pop(owner).items():
132
+ top.setdefault(f"{owner}.{nm}", sym)
133
+ # the app's own platform: one that tags at least half of the compared symbols (Element X iOS tags nearly every
134
+ # symbol `ios` from its Xcode targets). Code tagged with it is the app itself, not platform-only code (#107)
135
+ tags = defaultdict(int)
136
+ compared = list(types.values()) + list(top.values()) + [m for ms in members.values() for m in ms.values()]
137
+ for sym in compared:
138
+ for pl in set(sym.get("platforms") or ()):
139
+ tags[pl] += 1
140
+ primary = {pl for pl, n in tags.items() if n * 2 >= len(compared)}
141
+ return {"types": types, "members": members, "top": top, "err_files": err_files, "platforms": platforms, "db": db,
142
+ "support": support, "primary_platforms": primary, "root": root, "lines_of": lines_of}
143
+
144
+
145
+ def _first_word(name: str) -> str:
146
+ """`GreenCertificateVC` -> green, `OTPValidation` -> otp: fuzzy matches keep the first word."""
147
+ m = re.match(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z0-9]+|[A-Z]+", name or "")
148
+ return (m.group(0) if m else name or "").lower()
149
+
150
+
151
+ def _comment_above(root: Path, file: str, line: int | None, cache: dict) -> str:
152
+ """The `//` / `///` / `/* */` comment block right above a declaration (attributes / annotations skipped): the
153
+ graph keeps no doc comment for Swift and Kotlin, and `/// Android: ProfileFragment` lives there."""
154
+ if not line or not file:
155
+ return ""
156
+ if file not in cache:
157
+ try:
158
+ cache[file] = (root / file).read_text(encoding="utf-8", errors="replace").splitlines()
159
+ except OSError:
160
+ cache[file] = []
161
+ src, out, i = cache[file], [], line - 2
162
+ while 0 <= i < len(src) and len(out) < 12:
163
+ t = src[i].strip()
164
+ if t.startswith("@") and not out:
165
+ i -= 1
166
+ continue
167
+ if t.startswith(("//", "/*", "*", "*/")):
168
+ out.append(t)
169
+ i -= 1
170
+ continue
171
+ break
172
+ return "\n".join(reversed(out))
173
+
174
+
175
+ NOISE_WORDS = {"json", "impl", "default"}
176
+
177
+
178
+ def _bag(name: str) -> str:
179
+ """The words of a name without their order (and the UI suffix flag): a port that names `AddEditFolder` as
180
+ `FolderAddEdit`."""
181
+ k = key(name)
182
+ ws = [w for w in _words(name) if w not in UI_SUFFIXES]
183
+ if ws and ws[0] in _PREFIXES and len(ws) > 1:
184
+ ws = ws[1:]
185
+ return " ".join(sorted(ws)) + ("|ui" if k.endswith("|ui") else "")
186
+
187
+
188
+ def _words(name: str) -> list[str]:
189
+ ws = [w.lower() for w in re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z0-9]+|[A-Z]+", name or "")]
190
+ return [w for w in ws if w not in NOISE_WORDS] or ws
191
+
192
+
193
+ def _near(a: list[str], b: list[str]) -> bool:
194
+ if a == b:
195
+ return True
196
+ if len(a) == len(b):
197
+ diff = [(x, y) for x, y in zip(a, b) if x != y]
198
+ return len(diff) == 1 and min(len(diff[0][0]), len(diff[0][1])) >= 4 and \
199
+ (diff[0][0].startswith(diff[0][1]) or diff[0][1].startswith(diff[0][0]))
200
+ return False
201
+
202
+
203
+ def _short(fqn: str) -> str:
204
+ return fqn.rsplit(".", 1)[-1]
205
+
206
+
207
+ def _explicit(sym: dict) -> str | None:
208
+ m = EXPLICIT.search(sym.get("doc") or "")
209
+ return m.group(1) if m else None
210
+
211
+
212
+ def key(name: str) -> str:
213
+ """Normalized match key: the stem, plus `|ui` when a UI suffix was stripped, so `CartView` meets `CartScreen` but
214
+ a model type `Otp` does not meet `OtpFragment`."""
215
+ stem = norm(name)
216
+ return stem + ("|ui" if stem != _fold(name) else "")
217
+
218
+
219
+ def _cat(sym: dict, is_type: bool) -> str:
220
+ return "type" if is_type else "const" if sym["kind"] in ("constant", "enum_case") else "func"
221
+
222
+
223
+ def parity(source_db: str, target_db: str, mapping: dict | None = None, fuzzy: bool = True,
224
+ strip_prefixes: list[str] | None = None, structure: bool = False, learn: bool = True) -> dict:
225
+ """See the module docstring. `strip_prefixes`: name prefixes one side adds (`Vault` in `VaultAddEditState`
226
+ for `AddEditState`), dropped before normalized matching on both sides. `structure` (#93): after the name rules,
227
+ pair the rest by shared strings, localization keys, endpoints and paired callees, and (`learn`) apply rename
228
+ rules learned from the pairs found (codegraph/parity_structure.py); off, the output is the name matching alone."""
229
+ _PREFIXES[:] = [p.lower() for p in strip_prefixes or ()]
230
+ try:
231
+ return _parity(source_db, target_db, mapping, fuzzy, structure, learn)
232
+ finally:
233
+ _PREFIXES.clear()
234
+
235
+
236
+ def _parity(source_db: str, target_db: str, mapping: dict | None, fuzzy: bool, structure: bool = False,
237
+ learn: bool = True) -> dict:
238
+ S, T = load(source_db), load(target_db)
239
+ mapping = mapping or {}
240
+ exact_ix, norm_ix, comp_ix, all_short = defaultdict(list), defaultdict(list), defaultdict(list), defaultdict(list)
241
+ bag_ix = defaultdict(list)
242
+ t_explicit: dict = {}
243
+ for is_type, coll in ((True, T["types"]), (False, T["top"])):
244
+ for fq, sym in coll.items():
245
+ c, sh = _cat(sym, is_type), _short(fq)
246
+ exact_ix[(c, sh)].append(fq)
247
+ norm_ix[(c, key(sh))].append(fq)
248
+ bag_ix[(c, _bag(sh))].append(fq)
249
+ all_short[sh].append(fq)
250
+ if sym["composable"]:
251
+ comp_ix[norm(sh)].append(fq)
252
+ x = _explicit(sym) # "Port of: FooView" on the target side
253
+ if x:
254
+ t_explicit.setdefault(x, fq)
255
+ t_explicit.setdefault(_short(x), fq)
256
+
257
+ out = {"source": source_db, "target": target_db, "matched": [], "missing": [], "unknown": [], "platform_only": [],
258
+ "skipped": 0}
259
+
260
+ def bucket(sym):
261
+ if sym["file"] in S["err_files"]:
262
+ return "unknown"
263
+ pl = set(sym.get("platforms") or ())
264
+ if pl and T["platforms"] and not (pl & T["platforms"]) and not (pl & S["primary_platforms"]):
265
+ return "platform_only"
266
+ return "missing"
267
+
268
+ def find(fq, sym, c, has_body):
269
+ sh = _short(fq)
270
+ for want in (mapping.get(fq), mapping.get(sh), _explicit(sym)):
271
+ if want:
272
+ hit = (want if want in T["types"] or want in T["top"] else None) or (all_short.get(_short(want)) or [None])[0]
273
+ if hit:
274
+ return hit, "explicit"
275
+ hit = t_explicit.get(fq) or t_explicit.get(sh)
276
+ if hit:
277
+ return hit, "explicit"
278
+ if exact_ix.get((c, sh)):
279
+ return exact_ix[(c, sh)][0], "exact"
280
+ if norm_ix.get((c, key(sh))):
281
+ return norm_ix[(c, key(sh))][0], "normalized"
282
+ if len(_words(sh)) >= 3 and bag_ix.get((c, _bag(sh))): # `AddEditFolderView` / `FolderAddEditScreen`
283
+ return bag_ix[(c, _bag(sh))][0], "normalized"
284
+ if has_body and comp_ix.get(norm(sh)): # SwiftUI view -> @Composable function
285
+ return comp_ix[norm(sh)][0], "normalized"
286
+ return None, None
287
+
288
+ pending, tmap = [], {}
289
+ t_nested = defaultdict(list) # target type -> its nested types
290
+ for fq in T["types"]:
291
+ if "." in fq and fq.rsplit(".", 1)[0] in T["types"]:
292
+ t_nested[fq.rsplit(".", 1)[0]].append(fq)
293
+ for is_type, coll in ((True, S["types"]), (False, S["top"])):
294
+ for fq, sym in sorted(coll.items(), key=lambda kv: (kv[0].count("."), kv[0])): # outer types first
295
+ c = _cat(sym, is_type)
296
+ owner = fq.rsplit(".", 1)[0] if is_type and "." in fq and fq.rsplit(".", 1)[0] in S["types"] else None
297
+ if owner:
298
+ # a nested type (`State.FormField`) is looked for inside its owner's counterpart; elsewhere only by
299
+ # its exact name, so a generic `Keys` / `FieldType` does not meet an unrelated type
300
+ hit, how = None, None
301
+ inner = t_nested.get(tmap.get(owner), [])
302
+ sh = _short(fq)
303
+ for t in inner:
304
+ if _short(t) == sh:
305
+ hit, how = t, "exact"
306
+ break
307
+ else:
308
+ for t in inner:
309
+ if key(_short(t)) == key(sh):
310
+ hit, how = t, "normalized"
311
+ break
312
+ if not hit:
313
+ h2, how2 = find(fq, sym, c, False)
314
+ if how2 in ("explicit", "exact"):
315
+ hit, how = h2, how2
316
+ else:
317
+ hit, how = find(fq, sym, c, is_type and "body" in S["members"].get(fq, {}))
318
+ if hit:
319
+ if is_type:
320
+ tmap[fq] = hit
321
+ out["matched"].append(_row(sym, hit, how))
322
+ else:
323
+ pending.append((c, fq, sym, is_type, owner))
324
+ # fuzzy, word level: same category and UI-suffix flag, same words but one shortened or pluralised
325
+ # (`ItemList` / `ItemListing`, `Config` / `Configuration`, `PendingLogins` / `PendingLogin`); a word more or less
326
+ # (`LoginTOTPState` / `LoginState`) is not a match
327
+ pool = defaultdict(list)
328
+ if fuzzy:
329
+ for (c, k), fqs in norm_ix.items():
330
+ w = _words(_short(fqs[0]))
331
+ if w:
332
+ pool[(c, k.endswith("|ui"), w[0], w[-1])].append((w, fqs[0]))
333
+ for c, fq, sym, is_type, owner in pending:
334
+ w = _words(_short(fq))
335
+ hit = None
336
+ if fuzzy and len(w) >= 2 and not owner:
337
+ for tw, tfq in pool.get((c, key(_short(fq)).endswith("|ui"), w[0], w[-1]), ()):
338
+ if _near(w, tw):
339
+ hit = tfq
340
+ break
341
+ if hit:
342
+ if is_type:
343
+ tmap[fq] = hit
344
+ out["matched"].append(_row(sym, hit, "fuzzy"))
345
+ else:
346
+ out[bucket(sym)].append(_row(sym, None, None))
347
+ # members of matched types
348
+ t_member_owner = defaultdict(set) # normalized member name -> target owners
349
+ for o, ms in T["members"].items():
350
+ for k in ms:
351
+ t_member_owner[norm_member(k)].add(o)
352
+
353
+ def match_members(pairs):
354
+ for sfq, tfq in pairs.items():
355
+ tm = dict(T["members"].get(tfq, {}))
356
+ for nt in t_nested.get(tfq, ()): # Kotlin sealed-class actions / events: `data object LockClick`
357
+ tm.setdefault(_short(nt), T["types"][nt])
358
+ tm_norm = {norm_member(k): k for k in tm}
359
+ for nm, sym in sorted(S["members"].get(sfq, {}).items()):
360
+ if nm in ("body", "init", "deinit", "hash", "description", "encode", "hashValue") or sym["override"]:
361
+ out["skipped"] += 1 # lifecycle / platform overrides (viewDidLoad, onCreate) and boilerplate
362
+ continue
363
+ if nm in tm:
364
+ out["matched"].append(_row(sym, f"{tfq}.{nm}", "exact"))
365
+ elif norm_member(nm) in tm_norm:
366
+ out["matched"].append(_row(sym, f"{tfq}.{tm_norm[norm_member(nm)]}", "normalized"))
367
+ elif len(_words(nm)) >= 3 and len(t_member_owner.get(norm_member(nm), ())) == 1:
368
+ # a specific name (three words or more) declared on exactly one other target type: moved there
369
+ o = next(iter(t_member_owner[norm_member(nm)]))
370
+ hit = next(k for k in T["members"][o] if norm_member(k) == norm_member(nm))
371
+ out["matched"].append(_row(sym, f"{o}.{hit}", "moved"))
372
+ else:
373
+ out[bucket(sym)].append(_row(sym, None, None, owner_match=tfq))
374
+
375
+ match_members(tmap)
376
+ if structure:
377
+ _structure(S, T, out, tmap, learn)
378
+ inferred = {r["symbol"]: r["target"] for r in out["matched"] if r.get("confidence") in ("structure", "learned")
379
+ and r["symbol"] in S["types"] and r["target"] in T["types"]}
380
+ match_members(inferred)
381
+ counts = defaultdict(int)
382
+ for r in out["matched"]:
383
+ counts[r["confidence"]] += 1
384
+ out["summary"] = {"source_symbols": len(out["matched"]) + len(out["missing"]) + len(out["unknown"]) +
385
+ len(out["platform_only"]), "matched": dict(counts), "skipped_overrides": out["skipped"],
386
+ "skipped_test_support": S["support"],
387
+ "missing": len(out["missing"]), "unknown": len(out["unknown"]),
388
+ "platform_only": len(out["platform_only"])}
389
+ if structure:
390
+ out["summary"]["inferred"] = sum(1 for r in out["matched"] if r["confidence"] in ("structure", "learned"))
391
+ return out
392
+
393
+
394
+ def _structure(S, T, out, tmap, learn):
395
+ from . import parity_structure as PS
396
+ sym_of, cat_of, t_syms, t_cat = {}, {}, {}, {}
397
+ for G, syms, cats, owners in ((S, sym_of, cat_of, tmap), (T, t_syms, t_cat, None)):
398
+ for is_type, coll in ((True, G["types"]), (False, G["top"])):
399
+ for fq, sym in coll.items():
400
+ syms[fq], cats[fq] = sym, _cat(sym, is_type)
401
+ for o, ms in G["members"].items():
402
+ if owners is not None and o not in owners:
403
+ continue
404
+ for nm, sym in ms.items():
405
+ fq = f"{o}.{nm}"
406
+ syms[fq], cats[fq] = sym, _cat(sym, False)
407
+ out["learned_rules"] = PS.match(S, T, out, sym_of, cat_of, t_syms, t_cat, learn=learn)["rules"]
408
+
409
+
410
+ def rename_map(res: dict) -> dict:
411
+ """The structure / learned matches as a `--map` file ({source: target}) to review and commit."""
412
+ return {r["symbol"]: r["target"] for r in res["matched"] if r.get("confidence") in ("structure", "learned")}
413
+
414
+
415
+ def _row(sym, target, how, owner_match=None) -> dict:
416
+ r = {"symbol": sym["fqn"], "kind": sym["kind"], "file": sym["file"], "line": sym["line"],
417
+ "module": _group(sym["file"])}
418
+ if target:
419
+ r.update(target=target, confidence=how)
420
+ if owner_match:
421
+ r["owner_matched"] = owner_match
422
+ return r
423
+
424
+
425
+ def _group(file: str) -> str:
426
+ """Directory group: two levels, after a Gradle `src/<set>/<java|kotlin>/` and its package path."""
427
+ f = file or ""
428
+ m = re.search(r"(?:^|/)src/\w+/(?:java|kotlin)/(.*)$", f)
429
+ if m:
430
+ pkg = m.group(1).split("/")[:-1]
431
+ head = f[: m.start()].strip("/")
432
+ tail = "/".join(pkg[3:5] if len(pkg) > 3 else pkg[-2:])
433
+ return "/".join(x for x in (head, tail) if x)
434
+ parts = Path(f).parts
435
+ return "/".join(parts[:2]) if len(parts) > 2 else (parts[0] if parts else "")
436
+
437
+
438
+ def render(res: dict, max_items: int = 200) -> str:
439
+ s = res["summary"]
440
+ m = s["matched"]
441
+ lines = [f"parity: {res['source']} -> {res['target']}",
442
+ f"{s['source_symbols']} source symbols: {sum(m.values())} matched "
443
+ f"({', '.join(f'{k} {v}' for k, v in sorted(m.items()))}), {s['missing']} missing, "
444
+ f"{s['unknown']} unknown (syntax errors), {s['platform_only']} platform-only"]
445
+ by = defaultdict(list)
446
+ for r in res["missing"]:
447
+ by[r["module"]].append(r)
448
+ if by:
449
+ lines += ["", f"== MISSING in target: {s['missing']}"]
450
+ shown = 0
451
+ for mod in sorted(by, key=lambda k: -len(by[k])):
452
+ lines.append(f" {mod or '.'} ({len(by[mod])})")
453
+ for r in by[mod]:
454
+ if shown >= max_items:
455
+ break
456
+ shown += 1
457
+ lines.append(f" {r['symbol']} [{r['kind']}] {r['file']}:{r['line']}")
458
+ if shown < s["missing"]:
459
+ lines.append(f" ... {s['missing'] - shown} more (--json for all)")
460
+ inferred = [r for r in res["matched"] if r.get("confidence") in ("structure", "learned")]
461
+ if inferred:
462
+ lines += ["", f"== INFERRED matches (not by name): {len(inferred)}"]
463
+ for r in inferred[:max_items]:
464
+ sc = f" {r['score']}" if "score" in r else ""
465
+ lines.append(f" {r['symbol']} -> {r['target']} [{r['confidence']}{sc}] {', '.join(r.get('evidence') or [])}")
466
+ for rule in res.get("learned_rules") or []:
467
+ if rule is (res.get("learned_rules") or [None])[0]:
468
+ lines += ["", "== LEARNED rename rules"]
469
+ lines.append(f" {rule['at']}: {rule['from']!r} -> {rule['to']!r} (seen {rule['support']}x)")
470
+ if res["unknown"]:
471
+ lines += ["", f"== UNKNOWN (file parsed with syntax errors): {s['unknown']}"]
472
+ lines += [f" {r['symbol']} {r['file']}:{r['line']}" for r in res["unknown"][:max_items]]
473
+ return "\n".join(lines)