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,538 @@
|
|
|
1
|
+
"""Flutter framework plugin (on top of the Dart plugin).
|
|
2
|
+
|
|
3
|
+
contribute():
|
|
4
|
+
* widgets: StatelessWidget/StatefulWidget/State/Consumer*/Hook* classes get lifecycle CALLS
|
|
5
|
+
(createState/initState/didChangeDependencies/didUpdateWidget/build/dispose); creating a widget
|
|
6
|
+
(`X(...)`) is a RENDERS edge; StatefulWidget -> its State class via createState;
|
|
7
|
+
* bloc/cubit: `on<E>(handler)` registrations -> LISTENED_BY event class -> handler (subclasses of
|
|
8
|
+
E included), `bloc.add(E(...))` / `context.read<B>().add(..)` -> DISPATCHES; `emit(S(..))` /
|
|
9
|
+
`emit(state.copyWith(status: Enum.v))` -> EMITS_STATE; UI `is S`, `case S()`,
|
|
10
|
+
`state.status == Enum.v`, switch cases -> HANDLES_STATE. Per bloc a state-flow summary is kept on
|
|
11
|
+
the class node (attrs.state_flow): events dispatched without a handler, handlers never dispatched,
|
|
12
|
+
emitted states/status values no UI code checks;
|
|
13
|
+
* navigation: MaterialApp/CupertinoApp `routes:`/`home:`, go_router GoRoute trees (nested paths,
|
|
14
|
+
:param -> {param}), auto_route @RoutePage, `Navigator.push(MaterialPageRoute(builder: ..))`,
|
|
15
|
+
`pushNamed('/x')`, `context.go/push('/x')` -> page nodes (entry ui_page) + NAVIGATES_TO;
|
|
16
|
+
`main()` in lib/ is ui_global.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from ...core.model import EXACT, HEURISTIC, RESOLVED
|
|
23
|
+
from ...core.plugin import FrameworkPlugin, GraphBuilder, Project
|
|
24
|
+
from ..dart.http import UrlEval
|
|
25
|
+
from ..dart.program import Ctx, DartProgram, DClass, DFunc, ctor_type, root_name, split_type, walk_repr
|
|
26
|
+
|
|
27
|
+
WIDGET_BASES = {"StatelessWidget", "StatefulWidget", "Widget", "HookWidget", "StatefulHookWidget", "ConsumerWidget",
|
|
28
|
+
"ConsumerStatefulWidget", "HookConsumerWidget", "InheritedWidget", "InheritedNotifier", "InheritedModel",
|
|
29
|
+
"RenderObjectWidget", "LeafRenderObjectWidget", "SingleChildRenderObjectWidget", "MultiChildRenderObjectWidget",
|
|
30
|
+
"PreferredSizeWidget", "ProxyWidget", "ImplicitlyAnimatedWidget", "AnimatedWidget", "StatelessComponent",
|
|
31
|
+
"StatefulComponent", "GetView", "GetWidget"}
|
|
32
|
+
STATE_BASES = {"State", "ConsumerState"}
|
|
33
|
+
LIFECYCLE = ("createState", "initState", "didChangeDependencies", "didUpdateWidget", "build", "dispose", "deactivate", "activate")
|
|
34
|
+
BLOC_BASES = {"Bloc", "HydratedBloc", "ReplayBloc"}
|
|
35
|
+
CUBIT_BASES = {"Cubit", "HydratedCubit", "ReplayCubit"}
|
|
36
|
+
ROUTE_BASES = {"Route", "PageRoute", "ModalRoute", "MaterialPageRoute", "CupertinoPageRoute", "PageRouteBuilder",
|
|
37
|
+
"TransitionRoute", "PopupRoute"}
|
|
38
|
+
ROUTE_TYPES = {"MaterialPageRoute", "CupertinoPageRoute", "PageRouteBuilder", "MaterialWithModalsPageRoute", "DialogRoute",
|
|
39
|
+
"ModalBottomSheetRoute", "FadeRoute", "SlideRoute"}
|
|
40
|
+
PUSH = {"push", "pushReplacement", "pushAndRemoveUntil", "pushAndRemoveAll", "popAndPush", "restorablePush", "to", "off", "offAll"}
|
|
41
|
+
PUSH_NAMED = {"pushNamed", "pushReplacementNamed", "popAndPushNamed", "pushNamedAndRemoveUntil", "restorablePushNamed", "toNamed",
|
|
42
|
+
"offNamed", "offAllNamed"}
|
|
43
|
+
GO = {"go", "push", "replace", "pushReplacement", "goNamed", "pushNamed", "replaceNamed", "pushReplacementNamed"}
|
|
44
|
+
APP_TYPES = {"MaterialApp", "CupertinoApp", "WidgetsApp", "GetMaterialApp", "GetCupertinoApp"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def gopath(p: str) -> str:
|
|
48
|
+
return re.sub(r":(\w+)", r"{\1}", p)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FlutterPlugin(FrameworkPlugin):
|
|
52
|
+
name, language = "flutter", "dart"
|
|
53
|
+
|
|
54
|
+
def detect(self, project: Project) -> bool:
|
|
55
|
+
for p in [project.root / "pubspec.yaml"] + list(project.root.glob("*/pubspec.yaml")) + list(project.root.glob("*/*/pubspec.yaml")):
|
|
56
|
+
try:
|
|
57
|
+
if re.search(r"^\s+flutter:\s*$|sdk:\s*flutter", p.read_text(errors="replace"), re.M):
|
|
58
|
+
return True
|
|
59
|
+
except OSError:
|
|
60
|
+
continue
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def register_hooks(self, prog: DartProgram) -> None:
|
|
64
|
+
def state_rule(prog_, r, ctx: Ctx):
|
|
65
|
+
if r.get("k") == "id" and r["v"] == "state" and ctx.cls is not None and "state" not in ctx.locals:
|
|
66
|
+
s = self.state_type(prog_, ctx.cls)
|
|
67
|
+
if s is not None:
|
|
68
|
+
return ("inst", s, [])
|
|
69
|
+
return None
|
|
70
|
+
prog.type_rules.append(state_rule)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def bloc_kind(prog: DartProgram, c: DClass) -> str | None:
|
|
74
|
+
names = prog.ext_base_names(c)
|
|
75
|
+
if names & BLOC_BASES:
|
|
76
|
+
return "bloc"
|
|
77
|
+
if names & CUBIT_BASES:
|
|
78
|
+
return "cubit"
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def bloc_args(prog: DartProgram, c: DClass) -> list[str]:
|
|
83
|
+
for t in prog.lineage(c):
|
|
84
|
+
base, args, _ = split_type(t)
|
|
85
|
+
if base.split(".")[-1] in BLOC_BASES | CUBIT_BASES:
|
|
86
|
+
return args
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
def state_type(self, prog: DartProgram, c: DClass) -> DClass | None:
|
|
90
|
+
if self.bloc_kind(prog, c) is None:
|
|
91
|
+
return None
|
|
92
|
+
args = self.bloc_args(prog, c)
|
|
93
|
+
if args:
|
|
94
|
+
return prog.resolve_class(c.lib, split_type(args[-1])[0])
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
def contribute(self, project: Project, builder: GraphBuilder, prog: DartProgram) -> dict:
|
|
99
|
+
if prog is None:
|
|
100
|
+
return {"status": "no dart program"}
|
|
101
|
+
b = builder
|
|
102
|
+
st = {"widgets": 0, "states": 0, "renders": 0, "blocs": 0, "cubits": 0, "event_handlers": 0, "dispatches": 0,
|
|
103
|
+
"dispatch_unhandled": 0, "emits": 0, "handles_state": 0, "pages": 0, "navigations": 0, "ui_global": 0}
|
|
104
|
+
widget = {}
|
|
105
|
+
for c in prog.classes.values():
|
|
106
|
+
names = prog.ext_base_names(c) | {x.name for x in prog.mro(c)}
|
|
107
|
+
if names & WIDGET_BASES:
|
|
108
|
+
widget[c.id] = c
|
|
109
|
+
b.nodes[c.id].attrs["flutter"] = "widget"
|
|
110
|
+
st["widgets"] += 1
|
|
111
|
+
elif names & STATE_BASES:
|
|
112
|
+
b.nodes[c.id].attrs["flutter"] = "state"
|
|
113
|
+
st["states"] += 1
|
|
114
|
+
else:
|
|
115
|
+
continue
|
|
116
|
+
for m in LIFECYCLE:
|
|
117
|
+
fm, inh = prog.find_member(c, m)
|
|
118
|
+
if isinstance(fm, DFunc):
|
|
119
|
+
b.add_edge(c.id, fm.id, "CALLS", c.file, fm.line, RESOLVED, via="flutter-lifecycle")
|
|
120
|
+
# creating a widget renders it; StatefulWidget -> State through createState
|
|
121
|
+
for e in list(b.edges.values()):
|
|
122
|
+
if e.kind == "INSTANTIATES" and e.dst in widget:
|
|
123
|
+
b.add_edge(e.src, e.dst, "RENDERS", e.file, e.line, e.confidence)
|
|
124
|
+
st["renders"] += 1
|
|
125
|
+
if e.kind == "INSTANTIATES" and e.src.endswith(".createState") and b.nodes.get(e.dst) is not None:
|
|
126
|
+
owner = e.src.rsplit(".", 1)[0].replace("method:", "class:", 1)
|
|
127
|
+
b.add_edge(owner, e.dst, "RENDERS", e.file, e.line, EXACT, via="createState")
|
|
128
|
+
self.blocs(prog, b, st)
|
|
129
|
+
self.navigation(prog, b, st)
|
|
130
|
+
for fn in prog.funcs.values():
|
|
131
|
+
if fn.cls is None and fn.name == "main" and "/lib/" in "/" + fn.file and "test" not in fn.file.split("/")[0]:
|
|
132
|
+
b.nodes[fn.id].entry_kind = "ui_global"
|
|
133
|
+
st["ui_global"] += 1
|
|
134
|
+
return st
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------ bloc
|
|
137
|
+
def blocs(self, prog: DartProgram, b: GraphBuilder, st: dict) -> None:
|
|
138
|
+
blocs: dict[str, dict] = {}
|
|
139
|
+
for c in prog.classes.values():
|
|
140
|
+
kind = self.bloc_kind(prog, c)
|
|
141
|
+
if not kind:
|
|
142
|
+
continue
|
|
143
|
+
st["blocs" if kind == "bloc" else "cubits"] += 1
|
|
144
|
+
args = self.bloc_args(prog, c)
|
|
145
|
+
ev = prog.resolve_class(c.lib, split_type(args[0])[0]) if kind == "bloc" and len(args) >= 2 else None
|
|
146
|
+
sc = self.state_type(prog, c)
|
|
147
|
+
info = {"cls": c, "kind": kind, "event_base": ev, "state": sc, "handlers": [], "emits": [], "dispatched": []}
|
|
148
|
+
blocs[c.id] = info
|
|
149
|
+
b.nodes[c.id].attrs["flutter"] = kind
|
|
150
|
+
for ct in c.ctors.values():
|
|
151
|
+
for f in ct.facts:
|
|
152
|
+
if f["ft"] == "call" and f.get("n") == "on" and f.get("t") is None and f.get("ta"):
|
|
153
|
+
et = f["ta"][0]
|
|
154
|
+
ecls = prog.resolve_class(c.lib, split_type(et)[0])
|
|
155
|
+
a0 = (f.get("a") or [None])[0] or {}
|
|
156
|
+
h = None
|
|
157
|
+
if a0.get("k") == "id":
|
|
158
|
+
h = c.methods.get(a0["v"])
|
|
159
|
+
if h is None:
|
|
160
|
+
m, _ = prog.find_member(c, a0["v"])
|
|
161
|
+
h = m if isinstance(m, DFunc) else None
|
|
162
|
+
elif a0.get("k") == "fn":
|
|
163
|
+
h = c.methods.get(f"on<{et}>@{f.get('l')}")
|
|
164
|
+
if h is not None:
|
|
165
|
+
info["handlers"].append((ecls, et, h, f.get("l"), ct.file))
|
|
166
|
+
if "mapEventToState" in c.methods:
|
|
167
|
+
info["handlers"].append((ev, args[0] if args else "?", c.methods["mapEventToState"], c.methods["mapEventToState"].line, c.file))
|
|
168
|
+
for ecls, et, h, line, file in info["handlers"]:
|
|
169
|
+
st["event_handlers"] += 1
|
|
170
|
+
if ecls is None:
|
|
171
|
+
continue
|
|
172
|
+
b.add_edge(ecls.id, h.id, "LISTENED_BY", file, line, EXACT, via=f"on<{et}>", bloc=c.id)
|
|
173
|
+
for sub in self.subclasses(prog, ecls):
|
|
174
|
+
b.add_edge(sub.id, h.id, "LISTENED_BY", file, line, RESOLVED, via=f"on<{et}> (subtype)", bloc=c.id)
|
|
175
|
+
self._blocs = blocs
|
|
176
|
+
event_owner: dict[str, list[str]] = {}
|
|
177
|
+
for bid, info in blocs.items():
|
|
178
|
+
for ecls, et, h, line, file in info["handlers"]:
|
|
179
|
+
if ecls is not None:
|
|
180
|
+
for x in [ecls] + self.subclasses(prog, ecls):
|
|
181
|
+
event_owner.setdefault(x.id, []).append(bid)
|
|
182
|
+
if info["event_base"] is not None:
|
|
183
|
+
for x in [info["event_base"]] + self.subclasses(prog, info["event_base"]):
|
|
184
|
+
event_owner.setdefault(x.id, []).append(bid)
|
|
185
|
+
# dispatch: bloc.add(Event(...))
|
|
186
|
+
for fn in prog.funcs.values():
|
|
187
|
+
ctx = prog.ctx_of(fn)
|
|
188
|
+
for f in fn.facts:
|
|
189
|
+
if f["ft"] != "call" or f.get("n") != "add" or not f.get("a"):
|
|
190
|
+
continue
|
|
191
|
+
ev = None
|
|
192
|
+
for tgt, conf, via in prog.resolve_call(f["a"][0], ctx) if (f["a"][0] or {}).get("k") in ("call", "new") else []:
|
|
193
|
+
if isinstance(tgt, DClass):
|
|
194
|
+
ev = tgt
|
|
195
|
+
if ev is None:
|
|
196
|
+
continue
|
|
197
|
+
bloc, conf = None, RESOLVED
|
|
198
|
+
t = f.get("t")
|
|
199
|
+
if t is None and fn.cls is not None and fn.cls.id in blocs:
|
|
200
|
+
bloc, conf = blocs[fn.cls.id], EXACT
|
|
201
|
+
elif t is not None:
|
|
202
|
+
tt = prog.infer(t, ctx)
|
|
203
|
+
if tt and tt[0] == "inst" and tt[1] is not None and tt[1].id in blocs:
|
|
204
|
+
bloc = blocs[tt[1].id]
|
|
205
|
+
if bloc is None:
|
|
206
|
+
owners = sorted(set(event_owner.get(ev.id, [])))
|
|
207
|
+
if len(owners) == 1:
|
|
208
|
+
bloc, conf = blocs[owners[0]], HEURISTIC
|
|
209
|
+
else:
|
|
210
|
+
continue
|
|
211
|
+
handled = any(ecls is not None and (ecls is ev or ev in self.subclasses(prog, ecls)) for ecls, *_ in bloc["handlers"])
|
|
212
|
+
if bloc["kind"] == "cubit":
|
|
213
|
+
continue
|
|
214
|
+
b.add_edge(fn.id, ev.id, "DISPATCHES", fn.file, f.get("l"), conf, bloc=bloc["cls"].id,
|
|
215
|
+
**({"unhandled": True} if not handled else {}))
|
|
216
|
+
bloc["dispatched"].append((ev, fn, f.get("l"), handled))
|
|
217
|
+
st["dispatches"] += 1
|
|
218
|
+
if not handled:
|
|
219
|
+
st["dispatch_unhandled"] += 1
|
|
220
|
+
# emits
|
|
221
|
+
for bid, info in blocs.items():
|
|
222
|
+
c = info["cls"]
|
|
223
|
+
for m in list(c.methods.values()) + list(c.ctors.values()):
|
|
224
|
+
ctx = prog.ctx_of(m)
|
|
225
|
+
for f in m.facts:
|
|
226
|
+
if f["ft"] != "call" or f.get("n") not in ("emit",) or not f.get("a"):
|
|
227
|
+
continue
|
|
228
|
+
if f.get("t") is not None and root_name(f.get("t")) not in ("emit", "emitter"):
|
|
229
|
+
continue
|
|
230
|
+
a0 = f["a"][0] or {}
|
|
231
|
+
cls, values = None, {}
|
|
232
|
+
if a0.get("k") in ("new", "call"):
|
|
233
|
+
for tgt, conf, via in prog.resolve_call(a0, ctx):
|
|
234
|
+
if isinstance(tgt, DClass):
|
|
235
|
+
cls = tgt
|
|
236
|
+
if cls is None and a0.get("k") == "call" and a0.get("n") == "copyWith":
|
|
237
|
+
tt = prog.infer(a0.get("t"), ctx)
|
|
238
|
+
cls = tt[1] if tt and tt[0] == "inst" else info["state"]
|
|
239
|
+
for k_, v in (a0.get("na") or {}).items():
|
|
240
|
+
if v and v.get("k") == "prop" and (v.get("t") or {}).get("k") == "id" and v["t"]["v"][:1].isupper():
|
|
241
|
+
values[k_] = f"{v['t']['v']}.{v['n']}"
|
|
242
|
+
if cls is None:
|
|
243
|
+
continue
|
|
244
|
+
b.add_edge(m.id, cls.id, "EMITS_STATE", m.file, f.get("l"), EXACT, **({"values": values} if values else {}))
|
|
245
|
+
info["emits"].append((cls, values, m, f.get("l")))
|
|
246
|
+
st["emits"] += 1
|
|
247
|
+
# UI handling of states
|
|
248
|
+
state_classes: dict[str, str] = {}
|
|
249
|
+
status_enums: dict[str, str] = {}
|
|
250
|
+
for bid, info in blocs.items():
|
|
251
|
+
s = info["state"]
|
|
252
|
+
if s is None:
|
|
253
|
+
continue
|
|
254
|
+
for x in [s] + self.subclasses(prog, s):
|
|
255
|
+
state_classes[x.name] = bid
|
|
256
|
+
for fv in x.fields.values():
|
|
257
|
+
ft = prog.resolve_class(x.lib, split_type(fv.type)[0]) if fv.type else None
|
|
258
|
+
if ft is not None and ft.kind == "enum":
|
|
259
|
+
status_enums[ft.name] = bid
|
|
260
|
+
for cls, values, m, line in info["emits"]:
|
|
261
|
+
for v in values.values():
|
|
262
|
+
status_enums.setdefault(v.split(".")[0], bid)
|
|
263
|
+
handled: dict[str, set] = {bid: set() for bid in blocs}
|
|
264
|
+
handled_at: dict[str, list] = {}
|
|
265
|
+
for fn in prog.funcs.values():
|
|
266
|
+
in_bloc = fn.cls is not None and fn.cls.id in blocs
|
|
267
|
+
for f in fn.facts:
|
|
268
|
+
names = []
|
|
269
|
+
if f["ft"] == "is":
|
|
270
|
+
names = [split_type(f["type"])[0]]
|
|
271
|
+
elif f["ft"] == "switch":
|
|
272
|
+
names = [re.match(r"^[\w.]+", c_).group(0) for c_ in f.get("cases") or [] if re.match(r"^[\w.]+", c_)]
|
|
273
|
+
elif f["ft"] == "cmp":
|
|
274
|
+
names = [x for x in (f.get("a"), f.get("b")) if x and re.match(r"^[A-Z]\w*\.\w+$", x)]
|
|
275
|
+
for nm in names:
|
|
276
|
+
base = nm.split(".")[0]
|
|
277
|
+
if nm in state_classes or base in state_classes and "." not in nm:
|
|
278
|
+
bid = state_classes.get(nm) or state_classes.get(base)
|
|
279
|
+
cls = prog.resolve_class(fn.lib, nm)
|
|
280
|
+
if cls is not None and not in_bloc:
|
|
281
|
+
b.add_edge(fn.id, cls.id, "HANDLES_STATE", fn.file, f.get("l"), RESOLVED, how=f["ft"])
|
|
282
|
+
handled[bid].add(cls.name)
|
|
283
|
+
handled_at.setdefault(cls.name, []).append(f"{fn.file}:{f.get('l')}")
|
|
284
|
+
st["handles_state"] += 1
|
|
285
|
+
elif base in status_enums and "." in nm:
|
|
286
|
+
bid = status_enums[base]
|
|
287
|
+
cls = prog.resolve_class(fn.lib, base)
|
|
288
|
+
if cls is not None and not in_bloc:
|
|
289
|
+
b.add_edge(fn.id, cls.id, "HANDLES_STATE", fn.file, f.get("l"), RESOLVED, how=f["ft"], value=nm)
|
|
290
|
+
handled[bid].add(nm)
|
|
291
|
+
handled_at.setdefault(nm, []).append(f"{fn.file}:{f.get('l')}")
|
|
292
|
+
st["handles_state"] += 1
|
|
293
|
+
# per-bloc state flow summary
|
|
294
|
+
for bid, info in blocs.items():
|
|
295
|
+
c = info["cls"]
|
|
296
|
+
emitted = {}
|
|
297
|
+
for cls, values, m, line in info["emits"]:
|
|
298
|
+
if values:
|
|
299
|
+
for v in values.values():
|
|
300
|
+
if v.split(".")[0] in status_enums:
|
|
301
|
+
emitted.setdefault(v, []).append(f"{m.file}:{line}")
|
|
302
|
+
if cls is not info["state"] or not values:
|
|
303
|
+
emitted.setdefault(cls.name, []).append(f"{m.file}:{line}")
|
|
304
|
+
unhandled_states = sorted(k for k in emitted if k not in handled[bid] and not (info["state"] is not None and k == info["state"].name))
|
|
305
|
+
dispatched_ids = {ev.id for ev, *_ in info["dispatched"]}
|
|
306
|
+
never = []
|
|
307
|
+
for ecls, et, h, line, file in info["handlers"]:
|
|
308
|
+
if ecls is None:
|
|
309
|
+
continue
|
|
310
|
+
if not ({ecls.id} | {s.id for s in self.subclasses(prog, ecls)}) & dispatched_ids:
|
|
311
|
+
never.append({"event": et, "handler": h.id, "at": f"{file}:{line}"})
|
|
312
|
+
flow = {"kind": info["kind"], "state": info["state"].id if info["state"] else None,
|
|
313
|
+
"events_dispatched_unhandled": [{"event": ev.name, "at": f"{fn.file}:{l}"} for ev, fn, l, h in info["dispatched"] if not h],
|
|
314
|
+
"handlers_never_dispatched": never,
|
|
315
|
+
"emitted": {k: v[:4] for k, v in emitted.items()},
|
|
316
|
+
"emitted_not_handled_by_ui": unhandled_states,
|
|
317
|
+
"handled_by_ui": {k: handled_at.get(k, [])[:4] for k in sorted(handled[bid])}}
|
|
318
|
+
b.nodes[c.id].attrs["state_flow"] = flow
|
|
319
|
+
|
|
320
|
+
@staticmethod
|
|
321
|
+
def subclasses(prog: DartProgram, c: DClass) -> list[DClass]:
|
|
322
|
+
out, seen, stack = [], {c.id}, [c]
|
|
323
|
+
while stack:
|
|
324
|
+
x = stack.pop()
|
|
325
|
+
for s in prog.subs.get(x.id, []):
|
|
326
|
+
if s.id not in seen:
|
|
327
|
+
seen.add(s.id)
|
|
328
|
+
out.append(s)
|
|
329
|
+
stack.append(s)
|
|
330
|
+
return out
|
|
331
|
+
|
|
332
|
+
# ------------------------------------------------------------------ navigation
|
|
333
|
+
def page(self, b: GraphBuilder, prog: DartProgram, cls: DClass | None, route: str | None, file, line, via: str) -> str:
|
|
334
|
+
pk = cls.lib.pkg if cls is not None else (prog.pkg_of(file) if file else None)
|
|
335
|
+
pkg = pk.name if pk else None
|
|
336
|
+
key = f"dart:{pkg or ''}:{route}" if route else f"dart:{cls.file}#{cls.name}"
|
|
337
|
+
name = route or cls.name
|
|
338
|
+
pid = b.add_node("page", key, name, fqn=key, file=cls.file if cls else file, line=cls.line if cls else line, lang="dart",
|
|
339
|
+
entry_kind="ui_page", attrs={"route": route, "widget": cls.id if cls else None, "via": via})
|
|
340
|
+
if cls is not None:
|
|
341
|
+
b.add_edge(pid, cls.id, "RENDERS", file, line, EXACT, via=via)
|
|
342
|
+
return pid
|
|
343
|
+
|
|
344
|
+
PAGE_WRAPPERS = {"Page", "MaterialPage", "CupertinoPage", "CustomTransitionPage", "NoTransitionPage"}
|
|
345
|
+
|
|
346
|
+
def widgets_in(self, prog: DartProgram, r, ctx: Ctx) -> list[DClass]:
|
|
347
|
+
out = []
|
|
348
|
+
for x in walk_repr(r):
|
|
349
|
+
if x.get("k") == "fn":
|
|
350
|
+
for ret in x.get("ret") or []:
|
|
351
|
+
out.extend(self._screen_of(prog, ret, ctx))
|
|
352
|
+
return out
|
|
353
|
+
|
|
354
|
+
def _screen_of(self, prog: DartProgram, ret, ctx: Ctx, depth=0) -> list[DClass]:
|
|
355
|
+
"""The widget a route builder returns; a Page wrapper (`MaterialPage(child: X)`, a custom
|
|
356
|
+
`CustomTransitionPage` subclass) is looked through to its `child:`."""
|
|
357
|
+
if not isinstance(ret, dict) or ret.get("k") not in ("new", "call") or depth > 4:
|
|
358
|
+
return []
|
|
359
|
+
name = (ctor_type(ret) or "").split(".")[-1]
|
|
360
|
+
tg = [t for t, conf, via in prog.resolve_call(ret, ctx) if isinstance(t, DClass)]
|
|
361
|
+
is_page = name in self.PAGE_WRAPPERS or any(prog.ext_base_names(c) & self.PAGE_WRAPPERS for c in tg)
|
|
362
|
+
child = (ret.get("na") or {}).get("child")
|
|
363
|
+
if is_page and child is not None:
|
|
364
|
+
inner = self._screen_of(prog, child, ctx, depth + 1)
|
|
365
|
+
if not inner and isinstance(child, dict) and child.get("k") in ("new", "call") and \
|
|
366
|
+
(ctor_type(child) or "").split(".")[-1] == "Builder":
|
|
367
|
+
inner = self.widgets_in(prog, (child.get("na") or {}).get("builder"), ctx)
|
|
368
|
+
if inner:
|
|
369
|
+
return inner
|
|
370
|
+
return tg
|
|
371
|
+
|
|
372
|
+
def navigation(self, prog: DartProgram, b: GraphBuilder, st: dict) -> None:
|
|
373
|
+
widget_ids = {c.id for c in prog.classes.values()
|
|
374
|
+
if (prog.ext_base_names(c) | {x.name for x in prog.mro(c)}) & WIDGET_BASES}
|
|
375
|
+
ev = UrlEval(prog, {})
|
|
376
|
+
named_pages: dict[str, str] = {}
|
|
377
|
+
go_names: dict[str, str] = {}
|
|
378
|
+
|
|
379
|
+
# the extractor caps expression depth, so a deep route tree is truncated inside its root's repr;
|
|
380
|
+
# every call is also emitted as its own fact, so swap each nested call for its own full repr.
|
|
381
|
+
full_calls: dict = {}
|
|
382
|
+
|
|
383
|
+
def go_tree(r, prefix: str, ctx: Ctx, file, depth=0):
|
|
384
|
+
if not isinstance(r, dict) or depth > 60:
|
|
385
|
+
return
|
|
386
|
+
if r.get("k") in ("new", "call") and r.get("l"):
|
|
387
|
+
r = full_calls.get((file, r.get("l"), r.get("n")), r)
|
|
388
|
+
if (ctor_type(r) or "").split(".")[-1] == "GoRoute":
|
|
389
|
+
na = r.get("na") or {}
|
|
390
|
+
p = ev.eval(na["path"], ctx).text if na.get("path") else ""
|
|
391
|
+
full = p if p.startswith("/") else (prefix.rstrip("/") + "/" + p if p else prefix)
|
|
392
|
+
full = gopath(full or "/")
|
|
393
|
+
ws = self.widgets_in(prog, {"k": "list", "items": [na.get("builder"), na.get("pageBuilder")]}, ctx)
|
|
394
|
+
pid = None
|
|
395
|
+
for w in ws[:1] or [None]:
|
|
396
|
+
pid = self.page(b, prog, w, full, file, r.get("l"), "GoRoute")
|
|
397
|
+
st["pages"] += 1
|
|
398
|
+
named_pages[full] = pid
|
|
399
|
+
if na.get("name") and (na["name"].get("k") == "str"):
|
|
400
|
+
go_names[na["name"]["v"]] = pid
|
|
401
|
+
for ch in (na.get("routes") or {}).get("items") or []:
|
|
402
|
+
go_tree(ch, full, ctx, file, depth + 1)
|
|
403
|
+
return
|
|
404
|
+
for k_, v in r.items():
|
|
405
|
+
if k_ in ("l", "k"):
|
|
406
|
+
continue
|
|
407
|
+
if isinstance(v, dict):
|
|
408
|
+
go_tree(v, prefix, ctx, file, depth + 1)
|
|
409
|
+
elif isinstance(v, list):
|
|
410
|
+
for x in v:
|
|
411
|
+
go_tree(x, prefix, ctx, file, depth + 1)
|
|
412
|
+
|
|
413
|
+
# route tables (functions, field initialisers, top-level vars)
|
|
414
|
+
sources = []
|
|
415
|
+
for fn in prog.funcs.values():
|
|
416
|
+
sources.append((fn.facts, prog.ctx_of(fn), fn.file, fn))
|
|
417
|
+
for c in prog.classes.values():
|
|
418
|
+
sources.append(([{"ft": "init", "v": v.init} for v in c.fields.values() if v.init], Ctx(prog, c.lib, None, c), c.file, None))
|
|
419
|
+
for v in prog.vars:
|
|
420
|
+
sources.append(([{"ft": "init", "v": v.init}], Ctx(prog, v.lib, None, None), v.file, None))
|
|
421
|
+
for facts, ctx, file, fn in sources:
|
|
422
|
+
for f in facts:
|
|
423
|
+
if f.get("ft") in ("new", "call") and f.get("l"):
|
|
424
|
+
full_calls.setdefault((file, f.get("l"), f.get("n")), f)
|
|
425
|
+
GO_TYPES = ("GoRouter", "ShellRoute", "StatefulShellRoute", "StatefulShellBranch", "GoRoute")
|
|
426
|
+
# go_router trees: facts can list an inner GoRoute before the router that contains it, so first
|
|
427
|
+
# collect every route constructor nested inside another one and only walk the outermost trees.
|
|
428
|
+
nested_go: set = set()
|
|
429
|
+
for facts, ctx, file, fn in sources:
|
|
430
|
+
for f in facts:
|
|
431
|
+
r = f["v"] if f["ft"] == "init" else (f if f["ft"] in ("new", "call") else None)
|
|
432
|
+
if not isinstance(r, dict):
|
|
433
|
+
continue
|
|
434
|
+
for x in walk_repr(r):
|
|
435
|
+
if (ctor_type(x) or "").split(".")[-1] in GO_TYPES:
|
|
436
|
+
for y in walk_repr(x):
|
|
437
|
+
if y is not x and (ctor_type(y) or "").split(".")[-1] in GO_TYPES and y.get("l"):
|
|
438
|
+
nested_go.add((file, y.get("l"), y.get("c")))
|
|
439
|
+
seen_go = set()
|
|
440
|
+
for facts, ctx, file, fn in sources:
|
|
441
|
+
for f in facts:
|
|
442
|
+
roots = [f["v"]] if f["ft"] == "init" else ([f] if f["ft"] in ("new", "call") else [])
|
|
443
|
+
for r in roots:
|
|
444
|
+
if not isinstance(r, dict):
|
|
445
|
+
continue
|
|
446
|
+
for x in walk_repr(r):
|
|
447
|
+
k = x.get("k")
|
|
448
|
+
tname = (ctor_type(x) or "").split(".")[-1] or None
|
|
449
|
+
if tname in GO_TYPES:
|
|
450
|
+
key = (file, x.get("l"), x.get("c"))
|
|
451
|
+
if key in seen_go or key in nested_go:
|
|
452
|
+
continue
|
|
453
|
+
seen_go.add(key)
|
|
454
|
+
for y in walk_repr(x):
|
|
455
|
+
if y.get("k") in ("new", "call") and y.get("l"):
|
|
456
|
+
seen_go.add((file, y.get("l"), y.get("c")))
|
|
457
|
+
go_tree(x, "", ctx, file)
|
|
458
|
+
if tname in APP_TYPES:
|
|
459
|
+
na = x.get("na") or {}
|
|
460
|
+
routes = na.get("routes") or {}
|
|
461
|
+
for el in routes.get("entries") or []:
|
|
462
|
+
kk = el.get("key") or {}
|
|
463
|
+
path = kk.get("v") if kk.get("k") == "str" else ev.eval(kk, ctx).text if kk else None
|
|
464
|
+
ws = self.widgets_in(prog, el.get("value"), ctx)
|
|
465
|
+
pid = self.page(b, prog, ws[0] if ws else None, path, file, el.get("l"), "routes") if (ws or path) else None
|
|
466
|
+
if pid:
|
|
467
|
+
named_pages[path] = pid
|
|
468
|
+
st["pages"] += 1
|
|
469
|
+
if na.get("home") and na["home"].get("k") in ("new", "call"):
|
|
470
|
+
for tgt, conf, via in prog.resolve_call(na["home"], ctx):
|
|
471
|
+
if isinstance(tgt, DClass):
|
|
472
|
+
named_pages.setdefault("/", self.page(b, prog, tgt, "/", file, x.get("l"), "home"))
|
|
473
|
+
st["pages"] += 1
|
|
474
|
+
for c in prog.classes.values():
|
|
475
|
+
if any(a.split(".")[-1] in ("RoutePage", "AutoRoutePage") for a in c.ann):
|
|
476
|
+
self.page(b, prog, c, None, c.file, c.line, "@RoutePage")
|
|
477
|
+
st["pages"] += 1
|
|
478
|
+
# navigation calls
|
|
479
|
+
for fn in prog.funcs.values():
|
|
480
|
+
ctx = prog.ctx_of(fn)
|
|
481
|
+
for f in fn.facts:
|
|
482
|
+
k = f.get("k")
|
|
483
|
+
ct = (ctor_type(f) or "").split(".")[-1]
|
|
484
|
+
if ct not in ROUTE_TYPES and f.get("ft") in ("new", "call"):
|
|
485
|
+
# project route classes (`class ScreenRoute extends MaterialPageRoute`) and route helpers
|
|
486
|
+
# (`Route<T> buildScreenRoute({required Widget screen})`) construct a route as well
|
|
487
|
+
for tgt, conf, via in prog.resolve_call(f, ctx):
|
|
488
|
+
if isinstance(tgt, DClass) and prog.ext_base_names(tgt) & ROUTE_BASES:
|
|
489
|
+
ct = tgt.name
|
|
490
|
+
elif isinstance(tgt, DFunc) and split_type(tgt.raw.get("ret") or "")[0].split(".")[-1] in ROUTE_BASES:
|
|
491
|
+
ct = tgt.name
|
|
492
|
+
else:
|
|
493
|
+
continue
|
|
494
|
+
break
|
|
495
|
+
else:
|
|
496
|
+
ct = None
|
|
497
|
+
if ct:
|
|
498
|
+
na_vals = list((f.get("na") or {}).values()) + list(f.get("a") or [])
|
|
499
|
+
ws = self.widgets_in(prog, {"k": "list", "items": na_vals}, ctx)
|
|
500
|
+
if ct not in ROUTE_TYPES:
|
|
501
|
+
# a widget passed directly (`screen: GameScreen(...)`)
|
|
502
|
+
for v in na_vals:
|
|
503
|
+
if isinstance(v, dict) and v.get("k") in ("new", "call"):
|
|
504
|
+
ws += [t for t, _c, _v in prog.resolve_call(v, ctx) if isinstance(t, DClass) and t.id in widget_ids]
|
|
505
|
+
for w in ws:
|
|
506
|
+
pid = self.page(b, prog, w, None, fn.file, f.get("l"), ct)
|
|
507
|
+
b.add_edge(fn.id, pid, "NAVIGATES_TO", fn.file, f.get("l"), EXACT, via=ct)
|
|
508
|
+
st["navigations"] += 1
|
|
509
|
+
continue
|
|
510
|
+
if f["ft"] != "call":
|
|
511
|
+
continue
|
|
512
|
+
n, t, a = f.get("n"), f.get("t"), f.get("a") or []
|
|
513
|
+
rn = root_name(t) if t else None
|
|
514
|
+
path_r = None
|
|
515
|
+
if n in PUSH_NAMED and (rn == "Navigator" or rn == "Get"):
|
|
516
|
+
path_r = a[1] if rn == "Navigator" and len(a) > 1 and t.get("k") == "id" else (a[0] if a else None)
|
|
517
|
+
elif n in GO and t is not None and (rn in ("context", "ctx", "GoRouter", "router", "_router", "appRouter") or
|
|
518
|
+
(t.get("k") == "call" and t.get("n") == "of" and root_name(t.get("t")) == "GoRouter")):
|
|
519
|
+
path_r = a[0] if a else None
|
|
520
|
+
if path_r is None:
|
|
521
|
+
continue
|
|
522
|
+
if n.endswith("Named") and rn != "Navigator" and rn != "Get":
|
|
523
|
+
nm = path_r.get("v") if path_r.get("k") == "str" else None
|
|
524
|
+
pid = go_names.get(nm) if nm else None
|
|
525
|
+
route = None
|
|
526
|
+
else:
|
|
527
|
+
route = gopath(ev.eval(path_r, ctx).text).split("?")[0]
|
|
528
|
+
pid = named_pages.get(route)
|
|
529
|
+
if pid is None:
|
|
530
|
+
for pth, p_ in named_pages.items():
|
|
531
|
+
if pth and re.fullmatch(re.sub(r"\\\{\w+\\\}", "[^/]+", re.escape(pth)), route):
|
|
532
|
+
pid = p_
|
|
533
|
+
break
|
|
534
|
+
if pid is None and route and re.fullmatch(r"/[\w{}\-./:]*", route):
|
|
535
|
+
pid = b.add_node("page", f"dart::{route}", route, lang="dart", attrs={"route": route, "unresolved": True})
|
|
536
|
+
if pid:
|
|
537
|
+
b.add_edge(fn.id, pid, "NAVIGATES_TO", fn.file, f.get("l"), EXACT if route in named_pages else HEURISTIC, via=n)
|
|
538
|
+
st["navigations"] += 1
|
|
File without changes
|