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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- 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)
|