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
@@ -0,0 +1,307 @@
1
+ """`cg parity --structure` (#93): pairs symbols whose names do not match by what they use, and learns per-project
2
+ rename rules from the pairs found. See docs/parity.md, "Matching by structure".
3
+
4
+ Each still-missing source symbol and each unmatched target symbol gets a feature set from its declaration span and
5
+ its outgoing edges:
6
+
7
+ l10n:<key> localization keys (`Localizations.creatingAccount`, `L10n.actionCancel`, `R.string.creating_account`,
8
+ `BitwardenString.x`, `CommonStrings.x`), case and `_` folded so the two platforms' keys meet
9
+ str:<text> string literals of 5 to 80 characters (URL paths, analytics events, keys), leading `/` dropped
10
+ http:<path> HTTP endpoints the code calls (HTTP_CALLS), path parameters folded to `{}`
11
+ sym:<target> symbols it calls / instantiates / navigates to that are already paired (the source side is
12
+ rewritten to the target's name, so a call to a paired API on both sides is a shared feature)
13
+ call:<name> the names of the members it calls (two words or more)
14
+ w:<stem> the stemmed content words of its own name
15
+
16
+ A pair is accepted when it shares a use (any feature but w:) and either two uses or two name words, its IDF-weighted
17
+ cosine is at least MIN_SCORE, both symbols are each other's best candidate, the runner-up scores below MARGIN times
18
+ the best, and category, UI-ness and architecture role agree (`compatible`). Learned rules come from the
19
+ non-exact type pairs: a word tail (`Coordinator` -> `Navigation`) or head (`BW` -> `Bitwarden`) rewrite seen at least
20
+ MIN_SUPPORT times, two thirds of the time for that source tail, is applied to the remaining missing types and
21
+ top-level symbols. Every match carries its evidence; name matches are left as they are."""
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ import re
26
+ import sqlite3
27
+ from collections import Counter, defaultdict
28
+ from pathlib import Path
29
+
30
+ MIN_SCORE = 0.3
31
+ MIN_SHARED = 2
32
+ MARGIN = 0.8 # the runner-up must score below MARGIN * best
33
+ MIN_SUPPORT = 2
34
+ MAX_DF = 40
35
+ PREVIEW = re.compile(r"Preview") # Compose @Preview functions / PreviewParameterProviders, SwiftUI previews # features shared by more symbols than this do not propose candidates
36
+ L10N = re.compile(r"\b(?:Localizations|L10n|R\.string|BitwardenString|CommonStrings|[A-Z]\w*Strings)\.([A-Za-z_]\w*)")
37
+ STR = re.compile(r'"((?:[^"\\\n]|\\.){5,80})"')
38
+ EDGE_KINDS = ("CALLS", "INSTANTIATES", "NAVIGATES_TO", "HTTP_CALLS", "USES_VALUE", "REFERENCES_FN", "USES_TYPE")
39
+
40
+
41
+ UI_WORDS = {"view", "screen", "sheet", "page", "fragment", "activity", "controller", "vc", "content", "dialog", "card",
42
+ "row", "cell", "button", "item", "items", "field", "list", "menu", "toolbar", "banner"}
43
+ # role words of an architecture: a type ending in one only pairs with a type ending in the same group
44
+ ROLES = [{"processor", "viewmodel", "vm"}, {"coordinator", "navigation", "navigator", "route", "destination", "router"},
45
+ {"state"}, {"action"}, {"effect", "event"}, {"request", "api", "service", "client", "response", "json"},
46
+ {"repository", "manager", "service", "store", "source"}, {"model", "json", "data", "type", "option"}]
47
+ ROLE_OF = defaultdict(set)
48
+ for _i, _g in enumerate(ROLES):
49
+ for _w in _g:
50
+ ROLE_OF[_w].add(_i)
51
+
52
+
53
+ def _role(fq: str) -> set:
54
+ ws = _words(_short(fq))
55
+ if len(ws) >= 2 and ws[-2:] == ["view", "model"]:
56
+ return ROLE_OF["viewmodel"]
57
+ return ROLE_OF.get(ws[-1], set()) if ws else set()
58
+
59
+
60
+ def is_ui(fq: str, sym: dict, members: dict) -> bool:
61
+ ws = _words(_short(fq))
62
+ return bool(sym.get("composable") or "body" in members.get(fq, {}) or (ws and ws[-1] in UI_WORDS and ws[-2:] != ["view", "model"]))
63
+
64
+
65
+ def compatible(fq, sc, tq, tc, s_ui, t_ui) -> bool:
66
+ """Same category (a SwiftUI view type also meets a @Composable function), the same UI-ness for types and
67
+ top-level symbols, and no clash of architecture roles (`Coordinator` vs `State`)."""
68
+ if sc != tc and not ({sc, tc} == {"type", "func"} and s_ui and t_ui):
69
+ return False
70
+ if "type" in (sc, tc) and s_ui != t_ui:
71
+ return False
72
+ if sc == tc == "type":
73
+ a, b = _role(fq), _role(tq)
74
+ if a and b and not (a & b):
75
+ return False
76
+ return True
77
+
78
+
79
+ STOP = {"handle", "click", "clicked", "tap", "tapped", "pressed", "get", "set", "is", "on", "did", "will", "the", "to",
80
+ "for", "with", "and", "of", "default", "impl", "internal", "receive", "result", "update", "show", "new", "bitwarden"}
81
+ STOP |= UI_WORDS | {w for g in ROLES for w in g} | {"view", "model"}
82
+
83
+
84
+ def _stem(w: str) -> str:
85
+ for suf in ("ments", "ment", "ings", "ing", "ions", "ion", "ers", "er", "ed", "es", "s", "e"):
86
+ if w.endswith(suf) and len(w) - len(suf) >= 4:
87
+ w = w[: -len(suf)]
88
+ break
89
+ return w[:-1] if w.endswith("e") and len(w) > 4 else w
90
+
91
+
92
+ def name_words(fq: str) -> set:
93
+ """The stemmed content words of a name (role, UI and event words left out): `DeviceManagementProcessor` and
94
+ `ManageDevicesViewModel` share `devic` and `manag`."""
95
+ return {"w:" + _stem(w) for w in _words(_short(fq)) if w not in STOP and len(w) > 2}
96
+
97
+
98
+ def _fold(s: str) -> str:
99
+ return re.sub(r"[_\W]", "", s).lower()
100
+
101
+
102
+ def _words(name: str) -> list[str]:
103
+ return [w.lower() for w in re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z0-9]+|[A-Z]+", name or "")]
104
+
105
+
106
+ class Side:
107
+ """One graph: its symbols' spans, outgoing edges and source lines."""
108
+
109
+ def __init__(self, G: dict):
110
+ self.G = G
111
+ c = sqlite3.connect(G["db"])
112
+ c.row_factory = sqlite3.Row
113
+ self.root = G["root"]
114
+ self.lines = G["lines_of"]
115
+ self.fqn_of, self.span = {}, {}
116
+ self.by_file = defaultdict(list)
117
+ for r in c.execute("SELECT id, fqn, name, file, line, end_line FROM nodes WHERE file IS NOT NULL AND line IS NOT NULL"):
118
+ self.fqn_of[r["id"]] = (r["fqn"] or r["name"] or "").replace(".Companion.", ".")
119
+ self.by_file[r["file"]].append((r["line"], r["end_line"] or r["line"], r["id"]))
120
+ self.span[r["id"]] = (r["file"], r["line"], r["end_line"] or r["line"])
121
+ self.out = defaultdict(list)
122
+ q = f"SELECT src, dst, kind FROM edges WHERE kind IN ({','.join('?' * len(EDGE_KINDS))})"
123
+ for r in c.execute(q, EDGE_KINDS):
124
+ self.out[r["src"]].append((r["dst"], r["kind"]))
125
+ self.http = {r["id"]: (r["name"] or r["id"]) for r in c.execute("SELECT id, name FROM nodes WHERE kind='http'")}
126
+ c.close()
127
+
128
+ def text(self, file: str, a: int, b: int) -> str:
129
+ if file not in self.lines:
130
+ try:
131
+ self.lines[file] = (Path(self.root) / file).read_text(encoding="utf-8", errors="replace").splitlines()
132
+ except OSError:
133
+ self.lines[file] = []
134
+ return "\n".join(self.lines[file][a - 1:b])
135
+
136
+ def features(self, sym: dict, rename) -> set:
137
+ file, a, b = self.span.get(sym["id"], (sym["file"], sym["line"], sym["line"]))
138
+ f = set()
139
+ if file and a:
140
+ src = self.text(file, a, b)
141
+ f |= {"l10n:" + _fold(m) for m in L10N.findall(src)}
142
+ for m in STR.findall(src):
143
+ s = m.strip().lstrip("/")
144
+ if len(s) >= 5 and re.search(r"[A-Za-z]{3}", s) and not re.search(r"\\\(|\$\{|%[@sd]", s):
145
+ f.add("str:" + re.sub(r"\s+", " ", s.lower()))
146
+ for la, lb, nid in self.by_file.get(file, ()):
147
+ if a <= la <= b:
148
+ for dst, kind in self.out.get(nid, ()):
149
+ if kind == "HTTP_CALLS" or dst in self.http:
150
+ p = re.sub(r"\{[^}]*\}|(?<=/):\w+", "{}", self.http.get(dst, dst).split(" ")[-1])
151
+ f.add("http:" + p.lstrip("/").lower())
152
+ continue
153
+ dq = self.fqn_of.get(dst)
154
+ t = rename(dq)
155
+ if t:
156
+ f.add("sym:" + t)
157
+ # the called member's own name (two words or more): `getDevices` on an AuthService and on
158
+ # an AuthRepository is the same call even when the owners are not paired
159
+ nm = _short(dq or "").split("(")[0]
160
+ if kind == "CALLS" and len(_words(nm)) >= 2:
161
+ f.add("call:" + re.sub(r"^(get|fetch|load)(?=[a-z])", "", _fold(nm)))
162
+ return f
163
+
164
+
165
+ def match(S: dict, T: dict, out: dict, sym_of: dict, cat_of: dict, t_syms: dict, t_cat: dict, learn: bool = True) -> dict:
166
+ ui_s = {fq: is_ui(fq, sym, S["members"]) for fq, sym in sym_of.items()}
167
+ ui_t = {fq: is_ui(fq, sym, T["members"]) for fq, sym in t_syms.items()}
168
+ S["_ui"], T["_ui"] = ui_s, ui_t
169
+ """Adds `learned` and `structure` matches to `out` (rows moved out of `missing`). `sym_of` / `cat_of`: source fqn
170
+ -> symbol / category for every compared source symbol; `t_syms` / `t_cat` the same for the target."""
171
+ used = {r["target"] for r in out["matched"]}
172
+ rules: list = []
173
+ if learn:
174
+ rules = _learn(out["matched"], cat_of)
175
+ _apply_rules(rules, out, sym_of, cat_of, t_syms, t_cat, used)
176
+ _structure(S, T, out, sym_of, cat_of, t_syms, t_cat, used)
177
+ if learn:
178
+ more = [r for r in _learn(out["matched"], cat_of) if r["from"] not in {x["from"] for x in rules}]
179
+ if more:
180
+ _apply_rules(more, out, sym_of, cat_of, t_syms, t_cat, used)
181
+ rules += more
182
+ return {"rules": rules}
183
+
184
+
185
+ def _short(fq: str) -> str:
186
+ return fq.rsplit(".", 1)[-1]
187
+
188
+
189
+ def _learn(matched: list, cat_of: dict) -> list:
190
+ """Word tail / head rewrites from the non-exact type and top-level pairs."""
191
+ seen, total = Counter(), Counter()
192
+ for r in matched:
193
+ if r.get("confidence") in ("exact", "moved", None) or cat_of.get(r["symbol"]) not in ("type", "func", "const"):
194
+ continue
195
+ if r["symbol"].count(".") != r["target"].count(".") and "." in r["symbol"]:
196
+ continue
197
+ sw, tw = _words(_short(r["symbol"])), _words(_short(r["target"]))
198
+ p = 0
199
+ while p < min(len(sw), len(tw)) and sw[p] == tw[p]:
200
+ p += 1
201
+ q = 0
202
+ while q < min(len(sw), len(tw)) - p and sw[-1 - q] == tw[-1 - q]:
203
+ q += 1
204
+ if p and not q and 0 < len(sw) - p <= 2 and 0 < len(tw) - p <= 2:
205
+ k = ("tail", " ".join(sw[p:]), " ".join(tw[p:]))
206
+ elif q and not p and 0 < len(sw) - q <= 2 and 0 < len(tw) - q <= 2:
207
+ k = ("head", " ".join(sw[:len(sw) - q]), " ".join(tw[:len(tw) - q]))
208
+ else:
209
+ continue
210
+ seen[k] += 1
211
+ total[(k[0], k[1])] += 1
212
+ out = []
213
+ for (where, a, b), n in seen.most_common():
214
+ if n >= MIN_SUPPORT and n * 3 >= total[(where, a)] * 2 and not any(x["at"] == where and x["from"] == a for x in out):
215
+ out.append({"at": where, "from": a, "to": b, "support": n})
216
+ return out
217
+
218
+
219
+ def _apply_rules(rules, out, sym_of, cat_of, t_syms, t_cat, used):
220
+ if not rules:
221
+ return
222
+ t_ix = defaultdict(list)
223
+ for fq in t_syms:
224
+ if fq not in used:
225
+ t_ix[(t_cat[fq], " ".join(_words(_short(fq))))].append(fq)
226
+ keep = []
227
+ for row in out["missing"]:
228
+ fq = row["symbol"]
229
+ sw = " ".join(_words(_short(fq)))
230
+ hit = None
231
+ if "owner_matched" not in row:
232
+ for r in rules:
233
+ if r["at"] == "tail" and sw.endswith(" " + r["from"]):
234
+ want = sw[: -len(r["from"])] + r["to"]
235
+ elif r["at"] == "head" and sw.startswith(r["from"] + " "):
236
+ want = r["to"] + sw[len(r["from"]):]
237
+ else:
238
+ continue
239
+ c = [t for t in t_ix.get((cat_of.get(fq), want), ()) if t not in used]
240
+ if len(c) == 1:
241
+ hit = (c[0], r)
242
+ break
243
+ if hit:
244
+ used.add(hit[0])
245
+ r = hit[1]
246
+ out["matched"].append(dict(row, target=hit[0], confidence="learned",
247
+ evidence=[f"rule {r['at']} {r['from']!r} -> {r['to']!r} (seen {r['support']}x)"]))
248
+ else:
249
+ keep.append(row)
250
+ out["missing"] = keep
251
+
252
+
253
+ def _structure(S, T, out, sym_of, cat_of, t_syms, t_cat, used):
254
+ sS, sT = Side(S), Side(T)
255
+ pairs = {r["symbol"]: r["target"] for r in out["matched"]}
256
+ src_f, rows = {}, {r["symbol"]: r for r in out["missing"]}
257
+ for row in out["missing"]:
258
+ sym = sym_of.get(row["symbol"])
259
+ if sym is not None:
260
+ src_f[row["symbol"]] = sS.features(sym, lambda x: pairs.get(x)) | name_words(row["symbol"])
261
+ tgt_f = {fq: sT.features(sym, lambda x: x) | name_words(fq) for fq, sym in t_syms.items()
262
+ if fq not in used and not PREVIEW.search(_short(fq))}
263
+ src_f = {fq: f for fq, f in src_f.items() if not PREVIEW.search(_short(fq))}
264
+ df = Counter()
265
+ for fs in list(src_f.values()) + list(tgt_f.values()):
266
+ df.update(fs)
267
+ n = max(1, len(src_f) + len(tgt_f))
268
+ w = {f: math.log(1 + n / c) for f, c in df.items()}
269
+ inv = defaultdict(list)
270
+ for fq, fs in tgt_f.items():
271
+ for f in fs:
272
+ if df[f] <= MAX_DF:
273
+ inv[f].append(fq)
274
+
275
+ def score(a, b):
276
+ sh = a & b
277
+ nw = sum(1 for f in sh if not f.startswith("w:"))
278
+ # what they use must carry the match: two shared uses, or one use and two shared name words
279
+ if nw < 1 or (nw < MIN_SHARED and len(sh) - nw < 2):
280
+ return 0.0, sh
281
+ return sum(w[f] for f in sh) / math.sqrt(sum(w[f] for f in a) * sum(w[f] for f in b)), sh
282
+
283
+ best_s = {}
284
+ for fq, fs in src_f.items():
285
+ member = "owner_matched" in rows[fq]
286
+ cands = {t for f in fs if df[f] <= MAX_DF for t in inv.get(f, ())
287
+ if (t_cat.get(t) == cat_of.get(fq) if member else
288
+ compatible(fq, cat_of.get(fq), t, t_cat.get(t), S["_ui"].get(fq), T["_ui"].get(t)))}
289
+ scored = sorted(((score(fs, tgt_f[t]), t) for t in cands), key=lambda x: -x[0][0])
290
+ if scored and scored[0][0][0] >= MIN_SCORE and (len(scored) == 1 or scored[1][0][0] < MARGIN * scored[0][0][0]):
291
+ best_s[fq] = (scored[0][1], scored[0][0][0], scored[0][0][1])
292
+ # mutual best: no other source symbol scores higher for the same target
293
+ by_t = defaultdict(list)
294
+ for fq, (t, sc, sh) in best_s.items():
295
+ by_t[t].append((sc, fq))
296
+ keep = []
297
+ for row in out["missing"]:
298
+ fq = row["symbol"]
299
+ got = best_s.get(fq)
300
+ if got and max(by_t[got[0]])[1] == fq and got[0] not in used:
301
+ t, sc, sh = got
302
+ used.add(t)
303
+ ev = sorted(sh, key=lambda f: -w[f])[:6]
304
+ out["matched"].append(dict(row, target=t, confidence="structure", score=round(sc, 2), evidence=ev))
305
+ else:
306
+ keep.append(row)
307
+ out["missing"] = keep
codegraph/payload.py ADDED
@@ -0,0 +1,321 @@
1
+ """Payload / field contract check between a client endpoint and the backend route it matched.
2
+
3
+ Inputs (all from the graphs, no execution):
4
+ client HTTP_CALLS edge attrs: body_keys [{key,type,line,file,model?}], response_models [{model,path,line}],
5
+ response_keys [{key,path,line,cast,coalesce}], status_checks [{op,v,line}], trailing_slash;
6
+ model class attrs json_from / json_to (key, field, type, cast, nullable, required, ref, many), enum_values.
7
+ server route attrs: request {schemas:[{class,location}], keys:[...]}, response {schemas:[{class,status}],
8
+ shapes:[{status, keys: shape}]} (django/shapes.py), trailing_slash, framework, auth;
9
+ schema class attrs schema_fields (name/json, type, nullable, required, ref, many, choices).
10
+
11
+ Issue kinds: trailing_slash, request_missing_required, request_unknown_field, request_case_mismatch,
12
+ request_type, request_nullability, request_body_missing, response_missing_key, response_case_mismatch,
13
+ response_type, response_nullability, response_enum_values, status_code. Severity: high (likely runtime
14
+ failure), medium, low, info. Every issue carries file:line on both sides when known.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ DART_KIND = {"String": "str", "int": "int", "double": "float", "num": "number", "bool": "bool", "List": "list", "Iterable": "list",
21
+ "Set": "list", "Map": "dict", "DateTime": "datetime", "dynamic": "any", "Object": "any", "Uri": "str", "Duration": "any"}
22
+ SERVER_KIND = {"str": "str", "int": "int", "float": "float", "number": "number", "decimal": "decimal", "bool": "bool", "uuid": "str",
23
+ "datetime": "str", "date": "str", "time": "str", "duration": "any", "list": "list", "dict": "dict", "json": "any",
24
+ "file": "str", "model": "any", "any": "any", "null": "null", "email": "str", "url": "str", "fk": "int",
25
+ "m2m": "list", "bytes": "str", "enum": "str"}
26
+
27
+
28
+ def norm_key(k: str) -> str:
29
+ return re.sub(r"[_\-]", "", k).lower()
30
+
31
+
32
+ def dart_kind(t: str | None, enums: set[str] | None = None) -> tuple[str | None, bool]:
33
+ if not t:
34
+ return None, False
35
+ nullable = t.strip().endswith("?") or t.strip() in ("dynamic", "Object?")
36
+ base = re.sub(r"<.*", "", t.strip().rstrip("?")).split(".")[-1]
37
+ if enums and base in enums:
38
+ return "enum", nullable
39
+ return DART_KIND.get(base, "object" if base[:1].isupper() else None), nullable
40
+
41
+
42
+ def compatible(client: str | None, server: str | None, cast: str | None = None) -> bool:
43
+ if not client or not server or client in ("any",) or server in ("any", "null"):
44
+ return True
45
+ if client == server:
46
+ return True
47
+ if client == "number" and server in ("int", "float", "decimal", "number"):
48
+ return True
49
+ if client == "float" and server in ("int", "number", "decimal"):
50
+ return not (cast and cast.rstrip("?") == "double") # `as double` on a JSON int throws on the Dart VM
51
+ if client == "int" and server == "number":
52
+ return True
53
+ if client in ("str", "datetime", "enum") and server in ("str", "decimal", "enum"):
54
+ return True
55
+ if client == "object" and server in ("dict",):
56
+ return True
57
+ if client == "list" and server == "list":
58
+ return True
59
+ return False
60
+
61
+
62
+ class Checker:
63
+ def __init__(self, fe_nodes: dict, be_nodes: dict, fe_name: str, be_name: str):
64
+ self.fe, self.be = fe_nodes, be_nodes # id -> attrs
65
+ self.fe_name, self.be_name = fe_name, be_name
66
+ self.enums = {nid.split("#")[-1] for nid, a in fe_nodes.items() if a.get("enum_values")}
67
+
68
+ def at(self, side: str, file, line) -> str | None:
69
+ if not file:
70
+ return None
71
+ return f"{self.fe_name if side == 'fe' else self.be_name}/{file}:{line}"
72
+
73
+ # ---------------------------------------------------------------- server shapes
74
+ def schema_shape(self, qual: str, depth=0) -> dict:
75
+ a = self.be.get(f"class:{qual}") or {}
76
+ out = {}
77
+ for fd in a.get("schema_fields") or []:
78
+ ch = self.schema_shape(fd["ref"], depth + 1) if fd.get("ref") and depth < 5 else None
79
+ out[fd.get("json") or fd["name"]] = {"type": fd.get("type"), "nullable": fd.get("nullable"), "required": fd.get("required"),
80
+ "many": fd.get("many"), "children": ch, "line": fd.get("line"), "file": fd.get("file"),
81
+ "choices": fd.get("choices"), "read_only": fd.get("read_only"), "schema": qual,
82
+ "default": fd.get("has_default") or fd.get("default")}
83
+ return out
84
+
85
+ def success_shapes(self, ra: dict, errors: bool = False) -> list[dict]:
86
+ resp = ra.get("response") or {}
87
+ out = []
88
+ for s in resp.get("shapes") or []:
89
+ st = s.get("status")
90
+ if errors or st is None or (isinstance(st, int) and 200 <= st < 300):
91
+ out.append({"keys": s["keys"], "file": s.get("file"), "line": s.get("line"), "status": st})
92
+ for sc in resp.get("schemas") or []:
93
+ st = sc.get("status")
94
+ if errors or st is None or (isinstance(st, int) and 200 <= st < 300):
95
+ sh = self.schema_shape(sc["class"])
96
+ if sc.get("many"):
97
+ sh = {"[]": {"type": "list", "many": True, "children": sh}}
98
+ out.append({"keys": sh, "file": None, "line": None, "status": st, "schema": sc["class"]})
99
+ return out
100
+
101
+ @staticmethod
102
+ def at_path(shape: dict, path: str | None):
103
+ if not path:
104
+ return shape
105
+ cur = shape
106
+ for part in path.split("."):
107
+ many = part.endswith("[]")
108
+ part = part[:-2] if many else part
109
+ if part:
110
+ v = cur.get(part) if isinstance(cur, dict) else None
111
+ if not v:
112
+ return None
113
+ cur = v.get("children")
114
+ elif "[]" in cur:
115
+ cur = cur["[]"].get("children")
116
+ if cur is None:
117
+ return None
118
+ return cur
119
+
120
+ # ---------------------------------------------------------------- checks
121
+ def check(self, ep: str, route_id: str, ra: dict, call: dict) -> list[dict]:
122
+ issues = []
123
+ base = {"endpoint": ep, "route": route_id, "client_at": call.get("at"), "server_at": self.at("be", ra.get("_file"), ra.get("_line"))}
124
+
125
+ def add(kind, sev, msg, **kw):
126
+ issues.append({**base, "kind": kind, "severity": sev, "message": msg, **kw})
127
+
128
+ # trailing slash
129
+ cts, rts = call.get("trailing_slash"), ra.get("trailing_slash")
130
+ if cts is not None and rts is not None and bool(cts) != bool(rts):
131
+ method = ra.get("method", "")
132
+ fw = ra.get("framework")
133
+ sev = "high" if fw == "ninja" or method not in ("GET", "HEAD") else "medium"
134
+ add("trailing_slash", sev, f"client URL {'has' if cts else 'lacks'} a trailing slash, route `{ra.get('uri')}` "
135
+ f"{'has' if rts else 'does not'}; "
136
+ + ("django-ninja does not redirect: 404" if fw == "ninja" else
137
+ "APPEND_SLASH cannot redirect a " + method + " (RuntimeError in DEBUG, 404/405 otherwise)" if method not in ("GET", "HEAD")
138
+ else "GET is redirected (301) by APPEND_SLASH"), url=call.get("url"))
139
+ issues += self.check_request(ra, call, add)
140
+ issues_resp = self.check_response(ra, call, add)
141
+ issues += issues_resp
142
+ # status codes
143
+ statuses = {s.get("status") for s in (ra.get("response") or {}).get("shapes") or []} | \
144
+ {s.get("status") for s in (ra.get("response") or {}).get("schemas") or []}
145
+ ok = {s for s in statuses if isinstance(s, int) and 200 <= s < 300}
146
+ for sc in call.get("status_checks") or []:
147
+ if sc.get("v") == 200 and sc.get("op") in ("==", "!=") and ok and 200 not in ok and None not in statuses:
148
+ add("status_code", "high", f"client treats status {sc['op']} 200 as {'success' if sc['op'] == '==' else 'failure'}, "
149
+ f"server success responses use {sorted(ok)}", client_check=self.at("fe", sc.get("file"), sc.get("line")))
150
+ return issues
151
+
152
+ def check_request(self, ra, call, add):
153
+ req = ra.get("request") or {}
154
+ fields = {}
155
+ for sc in req.get("schemas") or []:
156
+ if sc.get("location") in ("body", "form", None):
157
+ for k, v in self.schema_shape(sc["class"]).items():
158
+ if not v.get("read_only"):
159
+ fields[k] = dict(v, partial=sc.get("partial"))
160
+ for k in req.get("keys") or []:
161
+ if k.get("location") in ("body", "form"):
162
+ fields.setdefault(k["name"], {"type": None, "required": not k.get("optional"), "line": k.get("line"), "file": k.get("file")})
163
+ body = call.get("body_keys") or []
164
+ if not fields:
165
+ return []
166
+ if not body:
167
+ req_fields = [k for k, v in fields.items() if v.get("required") and not v.get("partial")]
168
+ if req_fields and not call.get("body_opaque"):
169
+ add("request_body_missing", "high", f"client sends no JSON body; server requires {req_fields}",
170
+ server_field=self.at("be", fields[req_fields[0]].get("file"), fields[req_fields[0]].get("line")))
171
+ return []
172
+ ck = {b["key"]: b for b in body}
173
+ out_n = 0
174
+ for k, v in fields.items():
175
+ if k in ck:
176
+ b = ck[k]
177
+ kind, nullable = dart_kind(b.get("type"), self.enums)
178
+ sk = SERVER_KIND.get(v.get("type") or "", None)
179
+ if not compatible(kind, sk):
180
+ add("request_type", "medium", f"`{k}`: client sends {b.get('type')}, server expects {v.get('type')}",
181
+ key=k, client_field=self.at("fe", b.get("file"), b.get("line")), server_field=self.at("be", v.get("file"), v.get("line")))
182
+ if nullable and v.get("nullable") is False:
183
+ sev = "medium" if b.get("conditional") else "high" # unconditional key: an explicit null reaches validation
184
+ add("request_nullability", sev, f"`{k}`: client value is nullable ({b.get('type')}), server field is not nullable"
185
+ + ("" if b.get("conditional") else " -> a null value fails validation (422)"),
186
+ key=k, client_field=self.at("fe", b.get("file"), b.get("line")), server_field=self.at("be", v.get("file"), v.get("line")))
187
+ continue
188
+ close = [c for c in ck if norm_key(c) == norm_key(k)]
189
+ if close:
190
+ add("request_case_mismatch", "high", f"client sends `{close[0]}`, server field is `{k}`", key=k,
191
+ client_field=self.at("fe", ck[close[0]].get("file"), ck[close[0]].get("line")),
192
+ server_field=self.at("be", v.get("file"), v.get("line")))
193
+ elif v.get("required") and not v.get("partial") and not v.get("default"):
194
+ add("request_missing_required", "high", f"server requires `{k}` ({v.get('type')}); client body has {sorted(ck)}", key=k,
195
+ client_field=self.at("fe", body[0].get("file"), body[0].get("line")), server_field=self.at("be", v.get("file"), v.get("line")))
196
+ for c, b in ck.items():
197
+ if c not in fields and not any(norm_key(c) == norm_key(k) for k in fields):
198
+ add("request_unknown_field", "medium", f"client sends `{c}`, which the server schema does not declare (ignored/dropped)",
199
+ key=c, client_field=self.at("fe", b.get("file"), b.get("line")))
200
+ return []
201
+
202
+ def check_response(self, ra, call, add):
203
+ shapes = self.success_shapes(ra)
204
+ if not shapes:
205
+ return []
206
+ merged: dict = {}
207
+ for s in shapes:
208
+ for k, v in (s["keys"] or {}).items():
209
+ if k not in merged:
210
+ merged[k] = v
211
+ elif v.get("children") and not merged[k].get("children"):
212
+ merged[k] = v
213
+ # top-level keys read directly
214
+ for rk in call.get("response_keys") or []:
215
+ sh = self.at_path(merged, rk.get("path"))
216
+ if sh is None:
217
+ continue
218
+ if rk["key"] not in sh:
219
+ close = [k for k in sh if norm_key(k) == norm_key(rk["key"])]
220
+ if close:
221
+ add("response_case_mismatch", "high", f"client reads `{rk['key']}`, server sends `{close[0]}`", key=rk["key"],
222
+ client_field=self.at("fe", rk.get("file"), rk.get("line")))
223
+ else:
224
+ add("response_missing_key", "medium" if rk.get("coalesce") else "high",
225
+ f"client reads `{rk['key']}`{' at ' + rk['path'] if rk.get('path') else ''}; no success response of the route has it "
226
+ f"(server keys: {sorted(sh)[:12]})", key=rk["key"], client_field=self.at("fe", rk.get("file"), rk.get("line")),
227
+ server_shape=[self.at("be", s.get("file"), s.get("line")) for s in shapes if s.get("file")][:4])
228
+ by_model: dict[str, list] = {}
229
+ for rm in call.get("response_models") or []:
230
+ if rm.get("path") not in [x.get("path") for x in by_model.get(rm["model"], [])]:
231
+ by_model.setdefault(rm["model"], []).append(rm)
232
+ err_merged: dict = {}
233
+ for s in self.success_shapes(ra, errors=True):
234
+ for k, v in (s["keys"] or {}).items():
235
+ err_merged.setdefault(k, v)
236
+ for mid, rms in by_model.items():
237
+ ma = self.fe.get(mid) or {}
238
+ if not ma.get("json_from"):
239
+ continue
240
+ if re.search(r"(Error|Failure|Exception|Problem)", mid.split("#")[-1]):
241
+ merged_ = err_merged # error envelopes are parsed on non-2xx / success=false branches
242
+ else:
243
+ merged_ = merged
244
+ # the same model parsed at several JSON paths (`body['x']` with a fallback to `body`): judge the best-fitting one
245
+ best = None
246
+ for rm in rms:
247
+ path = rm.get("path")
248
+ sh = self.at_path(merged_, path) if path is not None else None
249
+ how = "path"
250
+ if sh is None and path is None:
251
+ cands = [("", merged_)] + [(k, v.get("children")) for k, v in merged_.items() if v.get("children")]
252
+ bo, pick = 0, None
253
+ for p_, c_ in cands:
254
+ o = len({x["key"] for x in ma["json_from"]} & set(c_ or {}))
255
+ if o > bo:
256
+ bo, pick = o, (p_, c_)
257
+ if pick is None:
258
+ continue
259
+ path, sh, how = pick[0], pick[1], "best-overlap"
260
+ if sh is None:
261
+ continue
262
+ tmp = []
263
+ self.compare_model(mid, ma, sh, path, lambda kind, sev, msg, **kw: tmp.append((kind, sev, msg, kw)), how, depth=0)
264
+ score = (sum(1 for t in tmp if t[1] == "high"), len(tmp))
265
+ if best is None or score < best[0]:
266
+ best = (score, tmp, path)
267
+ if best:
268
+ alt = [r.get("path") for r in rms if r.get("path") != best[2]]
269
+ for kind, sev, msg, kw in best[1]:
270
+ add(kind, sev, msg, **kw, **({"other_paths_parsed": alt} if alt else {}))
271
+ return []
272
+
273
+ def compare_model(self, mid, ma, sh, path, add, how, depth):
274
+ mname = mid.split("#")[-1]
275
+ for f in ma.get("json_from") or []:
276
+ k = f["key"]
277
+ loc = {"model": mname, "path": path or "", "key": k, "client_field": self.at("fe", f.get("file"), f.get("line")), "match": how}
278
+ v = sh.get(k)
279
+ if v is None:
280
+ close = [x for x in sh if norm_key(x) == norm_key(k)]
281
+ if close:
282
+ add("response_case_mismatch", "high", f"{mname}.fromJson reads `{k}`, server sends `{close[0]}`",
283
+ server_field=self.at("be", sh[close[0]].get("file"), sh[close[0]].get("line")), **loc)
284
+ else:
285
+ tolerant = f.get("nullable") or f.get("coalesce") or f.get("default")
286
+ add("response_missing_key", "low" if tolerant else "high",
287
+ f"{mname}.fromJson reads `{k}` ({f.get('cast') or f.get('type')}); the server response{' at ' + path if path else ''} "
288
+ f"has no such key" + (" (client tolerates null)" if tolerant else " -> cast of null fails"), **loc)
289
+ continue
290
+ sat = self.at("be", v.get("file"), v.get("line"))
291
+ ck, cnull = dart_kind(f.get("cast") or f.get("type"), self.enums)
292
+ if f.get("cast") is None and f.get("type"):
293
+ ck, cnull = dart_kind(f.get("type"), self.enums)
294
+ sk = SERVER_KIND.get(v.get("type") or "", None)
295
+ if ck == "object" and f.get("ref") and v.get("children") and depth < 4:
296
+ ra = self.fe.get(f["ref"]) or {}
297
+ self.compare_model(f["ref"], ra, v["children"], (path + "." if path else "") + k + ("[]" if f.get("many") else ""),
298
+ add, how, depth + 1)
299
+ continue
300
+ if f.get("many") and f.get("ref") and v.get("children") and depth < 4:
301
+ ra = self.fe.get(f["ref"]) or {}
302
+ self.compare_model(f["ref"], ra, v["children"], (path + "." if path else "") + k + "[]", add, how, depth + 1)
303
+ continue
304
+ if not compatible(ck if not f.get("many") else "list", sk, f.get("cast")):
305
+ add("response_type", "medium" if v.get("type") in ("fk", "model") or v.get("type") is None else "high", f"{mname}.`{k}`: client expects {f.get('cast') or f.get('type')}, server sends {v.get('type')}",
306
+ server_field=sat, **loc)
307
+ snull = bool(v.get("nullable")) or v.get("type") == "null"
308
+ if snull and f.get("nullable") is False and not f.get("coalesce") and not f.get("default"):
309
+ add("response_nullability", "high", f"{mname}.`{k}`: server may send null, client reads it as non-nullable "
310
+ f"{f.get('cast') or f.get('type')}", server_field=sat, **loc)
311
+ # enum values
312
+ if ck == "enum" and v.get("choices"):
313
+ en = re.sub(r"<.*", "", (f.get("type") or "").rstrip("?")).split(".")[-1]
314
+ ev = next((a.get("enum_values") for nid, a in self.fe.items() if nid.endswith("#" + en) and a.get("enum_values")), None)
315
+ if ev:
316
+ cvals = {e["wire"] for e in ev}
317
+ svals = {c if not isinstance(c, (list, tuple)) else c[0] for c in v["choices"]}
318
+ if cvals != svals:
319
+ add("response_enum_values", "high" if svals - cvals else "low",
320
+ f"{mname}.`{k}` enum {en}: client values {sorted(cvals)}, server choices {sorted(map(str, svals))}",
321
+ server_field=sat, **loc)