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
|
@@ -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)
|