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,1961 @@
1
+ """Kotlin language plugin (Android, Kotlin Multiplatform, Ktor, Spring).
2
+
3
+ Heuristic mode: a tree-sitter-kotlin syntax layer. Declarations (packages, classes, interfaces, objects, companion
4
+ objects, top-level / extension functions, methods) become nodes; calls are resolved by name: the enclosing class and
5
+ its supertypes, then a parameter / property type (`api.order()` with `api: OrdersApi`), imports, the same package, and
6
+ finally the name alone (one method: `binding: name`; two to five: a `candidate` edge to each). Every resolved reference is labelled `heuristic`, as for Rust and C / C++ without
7
+ their indexers. Exact mode (exact.py): a scip-java index of the Gradle / Maven build (`--scip`,
8
+ CODEGRAPH_KOTLIN_SCIP_FILE, or an opted-in scip-java run with CODEGRAPH_KOTLIN_SCIP=1) replaces the call edges with
9
+ compiler-resolved ones; without a JDK / scip-java the heuristic layer stays, and coverage says why.
10
+
11
+ Framework facts read from the same syntax tree:
12
+ HTTP clients Retrofit interfaces (@GET("users/{id}") ...; base URL from Retrofit.Builder().baseUrl("...")),
13
+ per-interface / BuildConfig base URLs, Ktor client (client.get("..."), client.get { url("...") },
14
+ client.request { method = HttpMethod.Post }), OkHttp Request.Builder().url("...") -> http:<METHOD> <path>
15
+ servers Ktor routing { route("/a") { get("/{id}") { } } } and type-safe resources get<Res> { } with
16
+ authenticate("x") { } as a guard; SecurityFilterChain requestMatchers(...).hasRole(...) as guards;
17
+ Spring @RestController / @RequestMapping / @GetMapping ... with @PreAuthorize / @Secured /
18
+ @RolesAllowed -> route:<METHOD> <uri>; @Scheduled (scheduled) and @KafkaListener / @RabbitListener /
19
+ @JmsListener / @EventListener (listener) entry points
20
+ Android AndroidManifest.xml activities (ui_page), services / receivers / providers (listener) and deep links
21
+ (<data scheme/host/path>) as pages; Worker / CoroutineWorker / JobService subclasses (queue_job);
22
+ Jetpack Compose Navigation composable("orders/{id}") / composable<OrderRoute>(...) { } / Navigation 3
23
+ entry<Key> { } as pages and
24
+ navController.navigate(...) as NAVIGATES_TO
25
+ Multiplatform KMP source sets (androidMain, iosMain, jvmMain, jsMain ...) as platform conditions (#7 tags), and
26
+ `expect` declarations -> their `actual` implementations (IMPLEMENTED_BY)
27
+ tables Spring Data repositories (JpaRepository<Entity, ID> ...) and Exposed table objects -> READS_TABLE /
28
+ WRITES_TABLE
29
+ Test code (src/test, src/androidTest, *Test source sets, *Test.kt) carries attrs.test; @Test / @ParameterizedTest /
30
+ @RepeatedTest / @TestFactory functions are `test` entries with attrs.framework (junit5, junit4, kotlin-test, testng, kotest)
31
+ from the file's imports; they count as test cases in `cg tests`.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import re
37
+ import time
38
+ import xml.etree.ElementTree as ET
39
+ from collections import defaultdict
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+
43
+ from ...core.syntax_errors import merge, tree_spans
44
+ from .reparse import reparse_members
45
+ from ...core.fsutil import keep_file
46
+ from ...core.model import EXACT, HEURISTIC, RESOLVED
47
+ from ...core.paths import rules as path_rules
48
+ from ...core.plugin import GraphBuilder, LanguagePlugin, Project
49
+ from ..native.ts import TreeSitterMissing
50
+
51
+ EXTS = (".kt", ".kts")
52
+ # an unknown receiver whose method name is declared on more classes than this gets no candidate edges (#83 item 6)
53
+ MAX_CANDIDATES = 5
54
+ VERBS = {"get", "post", "put", "delete", "patch", "head", "options"}
55
+ RETROFIT = {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
56
+ SPRING_MAP = {"GetMapping": "GET", "PostMapping": "POST", "PutMapping": "PUT", "DeleteMapping": "DELETE",
57
+ "PatchMapping": "PATCH", "RequestMapping": None}
58
+ # Spring Data repository interfaces (`interface OwnerRepository : JpaRepository<Owner, Int>`) and Exposed tables
59
+ # (`object Users : IntIdTable("users")`): calls on them read / write the table
60
+ REPO_SUPERS = r"(?:Jpa|Crud|ListCrud|PagingAndSorting|ListPagingAndSorting|CoroutineCrud|CoroutineSorting|ReactiveCrud|" \
61
+ r"ReactiveSorting|R2dbc|Mongo|ReactiveMongo|Kotlin)?Repository"
62
+ EXPOSED_TABLES = r"(?:Table|IdTable|IntIdTable|LongIdTable|UUIDTable|UIntIdTable|ULongIdTable|CompositeIdTable)"
63
+ DATA_WRITE = re.compile(r"^(save|delete|remove|insert|update|upsert|batchInsert|batchUpsert|replace|persist|merge|"
64
+ r"flush|truncate)")
65
+ DATA_READ = re.compile(r"^(find|get|read|query|search|stream|count|exists|select|selectAll|all|slice|fetch|load)")
66
+ # Spring Security SecurityFilterChain rules: requestMatchers("/admin/**").hasRole("ADMIN") / Kotlin DSL authorize(...)
67
+ SEC_AUTH = r"(hasRole|hasAnyRole|hasAuthority|hasAnyAuthority|authenticated|fullyAuthenticated|permitAll|denyAll|access)"
68
+ SPRING_GUARDS = {"PreAuthorize", "Secured", "RolesAllowed", "PostAuthorize"}
69
+ LISTENERS = {"KafkaListener", "RabbitListener", "JmsListener", "SqsListener", "EventListener", "StreamListener"}
70
+ WORKER_BASES = {"Worker", "CoroutineWorker", "ListenableWorker", "RxWorker", "JobService", "JobIntentService"}
71
+ LIFECYCLE = re.compile(r"^(on[A-Z]\w*|doWork|startWork|createWork|query|insert|update|delete|getType)$")
72
+ TEST_ANNOTATIONS = {"Test", "ParameterizedTest", "RepeatedTest", "TestFactory", "TestTemplate"}
73
+ TEST_FRAMEWORKS = (("org.junit.jupiter.", "junit5"), ("org.junit.", "junit4"), ("kotlin.test.", "kotlin-test"),
74
+ ("org.testng.", "testng"), ("io.kotest.", "kotest"))
75
+ TEST_PATH = re.compile(r"(^|/)(src/(test|androidTest|testDebug|testRelease|\w+Test)/|tests?/)|(Test|Tests|Spec)\.kt$")
76
+ # `iosMain`, and the test sets built for one platform (`iosTest`, `androidUnitTest`, `androidInstrumentedTest`, #86)
77
+ SOURCE_SET = re.compile(r"(?:^|/)src/((\w+?)(?:Main|Test|UnitTest|InstrumentedTest))/")
78
+ SET_PLATFORM = {"android": "android", "androidHost": "android", "androidDevice": "android", "ios": "ios",
79
+ "iosArm64": "ios", "iosX64": "ios", "iosSimulatorArm64": "ios",
80
+ "apple": "ios", "macos": "macos", "macosArm64": "macos", "macosX64": "macos", "js": "web",
81
+ "wasmJs": "web", "wasm": "web", "linux": "linux", "linuxX64": "linux", "mingw": "windows",
82
+ "mingwX64": "windows"}
83
+ TEMPLATE = re.compile(r"\$\{([^{}]*)\}|\$([A-Za-z_]\w*)")
84
+ # Ktor path parameters `{id}` / `{id:regex}` normalized to `{id}` for route keys
85
+ _KTOR_PARAM_RE = re.compile(r"\{(\w+)(?::[^{}]*)?\}")
86
+
87
+
88
+ def parser():
89
+ try:
90
+ from tree_sitter import Language, Parser
91
+ import tree_sitter_kotlin as m
92
+ except ImportError as e: # pragma: no cover - depends on the environment
93
+ raise TreeSitterMissing(f"tree-sitter grammar for kotlin not installed ({e}); "
94
+ f"pip install tree-sitter tree-sitter-kotlin") from e
95
+ return Parser(Language(m.language()))
96
+
97
+
98
+ def source_files(root: Path, project=None) -> list[str]:
99
+ rules = path_rules(project, "kotlin")
100
+ out = []
101
+ for dp, dn, fn in os.walk(root):
102
+ rd = os.path.relpath(dp, root).replace(os.sep, "/")
103
+ rd = "" if rd == "." else rd
104
+ dn[:] = sorted(rules.prune(rd, dn, dot=True))
105
+ for f in sorted(fn):
106
+ if f.endswith(EXTS):
107
+ rel = f"{rd}/{f}" if rd else f
108
+ if keep_file(os.path.join(dp, f)) and not rules.excluded(rel):
109
+ out.append(rel)
110
+ return out
111
+
112
+
113
+ def template(raw: str) -> str:
114
+ """Kotlin string literal source -> URL template: "orders/$id" -> orders/{id}, "${base}/x" -> {base}/x."""
115
+ s = raw
116
+ if s.startswith('"""'):
117
+ s = s[3:-3]
118
+ elif s.startswith('"'):
119
+ s = s[1:-1]
120
+
121
+ def rep(m):
122
+ expr = (m.group(1) or m.group(2) or "").strip()
123
+ name = re.sub(r"[^\w.]", "", expr).split(".")[-1] or "?"
124
+ return "{" + name + "}"
125
+ return TEMPLATE.sub(rep, s)
126
+
127
+
128
+ def split_url(t: str) -> tuple[str | None, str]:
129
+ """(origin, path) of a URL template; origin = scheme://host or a leading {placeholder}; query dropped."""
130
+ t = t.split("?")[0].split("#")[0]
131
+ m = re.match(r"^([a-zA-Z][\w+.-]*://[^/]*)(.*)$", t)
132
+ origin = None
133
+ if m:
134
+ origin, t = m.group(1), m.group(2)
135
+ else:
136
+ m = re.match(r"^(\{[^{}/]*\})(/.*|)$", t)
137
+ if m and not t.startswith("{/"):
138
+ origin, t = m.group(1), m.group(2)
139
+ if not t.startswith("/"):
140
+ t = "/" + t
141
+ return origin, t
142
+
143
+
144
+ def join_path(base: str, p: str) -> str:
145
+ if not p:
146
+ return base or "/"
147
+ if p.startswith("/") and base:
148
+ return base.rstrip("/") + p
149
+ return (base.rstrip("/") + "/" + p) if base else ("/" + p.lstrip("/"))
150
+
151
+
152
+ @dataclass
153
+ class Decl:
154
+ id: str
155
+ kind: str
156
+ name: str
157
+ fqn: str
158
+ file: str
159
+ line: int
160
+ end: int
161
+ cls: str | None = None # enclosing class fqn
162
+ supers: list = field(default_factory=list)
163
+ annotations: list = field(default_factory=list) # (name, first string arg or None, raw text)
164
+ modifiers: set = field(default_factory=set)
165
+ receiver: str | None = None
166
+ types: dict = field(default_factory=dict) # param / property name -> type name
167
+ test: bool = False
168
+ name_line: int | None = None # line of the name identifier (SCIP definitions sit there)
169
+
170
+
171
+ def _module_root(rel: str) -> str:
172
+ """The Gradle module directory of a source file: the path before its `src/` directory."""
173
+ i = rel.find("/src/")
174
+ return "" if rel.startswith("src/") or i < 0 else rel[:i]
175
+
176
+
177
+
178
+ # tree-sitter-kotlin 1.1.0 cannot parse a suspend lambda used as an expression (`val b = suspend { 1 }`,
179
+ # `X to suspend { ... }`): the error swallows the declarations around it (#81). `suspend` becomes seven spaces in
180
+ # front of the `{`, the same length, so byte offsets and the names read from the original source stay as written.
181
+ _SUSPEND_LAMBDA = re.compile(rb"(=|\(|,|\[|\bto|\breturn|->|&&|\|\||\?:)(\s*)suspend(\s*\{)")
182
+
183
+
184
+ # A suspend lambda that starts a statement (`suspend { ... }.runCatchingUpdatingState(state)`) errors the same way;
185
+ # blanked, the lambda would become the trailing lambda of the previous line's call, so `suspend` becomes `;` and six
186
+ # spaces (#104). Not after a line that continues into it (an operator, `=`, `(`, `,`, `.`).
187
+ _SUSPEND_STMT = re.compile(rb"(?m)^([ \t]*)suspend([ \t]*\{)")
188
+ _CONTINUES = (b"=", b"(", b",", b"[", b".", b"->", b"&&", b"||", b"?:", b"+", b"-", b"*", b"/", b"to", b"return")
189
+
190
+
191
+ def _suspend_lambdas(src: bytes) -> tuple[bytes, int]:
192
+ if b"suspend" not in src:
193
+ return src, 0
194
+ out, n = _SUSPEND_LAMBDA.subn(lambda m: m.group(1) + m.group(2) + b" " * 7 + m.group(3), src)
195
+
196
+ def stmt(m):
197
+ nonlocal n
198
+ before = out[:m.start()].rstrip()
199
+ prev = before[before.rfind(b"\n") + 1:].split(b"//")[0].rstrip()
200
+ if prev.endswith(_CONTINUES):
201
+ return m.group(0)
202
+ n += 1
203
+ return m.group(1) + b";" + b" " * 6 + m.group(2)
204
+ if b"suspend" in out:
205
+ out = _SUSPEND_STMT.sub(stmt, out)
206
+ return out, n
207
+
208
+
209
+ # A statement line starting with `get` / `set` right after a `val` / `var` (Ktor routing: `val x = ...` then
210
+ # `get("/path") { }`) parses as that property's accessor and the error swallows the whole function (#104). A getter
211
+ # or setter takes `()` / `(value)`, never a string, a lambda or type arguments, so for `get("...")`, `get { }` and
212
+ # `get<T>` the whitespace character in front of `get` / `set` becomes `;`. That ends the declaration and keeps every
213
+ # byte offset.
214
+ _ROUTE_ACCESSOR = re.compile(rb"([ \t]*)[ \t]((?:get|set)\s*(?:\(\s*(?:\"|\"\"\")|\{|<))")
215
+ _DECL_LINE = re.compile(rb"^\s*(?:(?:private|internal|public|protected|const|lateinit|override|open)\s+)*(?:val|var)\s")
216
+
217
+
218
+ def _route_calls(src: bytes) -> tuple[bytes, int]:
219
+ if b"get" not in src and b"set" not in src:
220
+ return src, 0
221
+ lines = src.split(b"\n")
222
+ n, prev = 0, b""
223
+ for i, ln in enumerate(lines):
224
+ m = _ROUTE_ACCESSOR.match(ln)
225
+ # only after a complete declaration line: `val h = X().apply {` + `set("k", v)` is a call inside the lambda
226
+ if m and _DECL_LINE.match(prev) and not prev.rstrip().endswith((b"{", b"(", b"[", b",", b"=", b"->", b".")):
227
+ lines[i] = m.group(1) + b";" + ln[m.end(1) + 1:]
228
+ n += 1
229
+ t = ln.strip()
230
+ if t and not t.startswith((b"//", b"/*", b"*", b"@")):
231
+ prev = ln
232
+ return (b"\n".join(lines), n) if n else (src, 0)
233
+
234
+
235
+ # `dynamic` is the Kotlin/JS type keyword in the grammar, so a call or declaration named `dynamic` (`fun
236
+ # Route.dynamic()`, `dynamic()` in a Ktor module) is a parse error. Its last letter is upper-cased in the parsed copy
237
+ # only; names come from the original bytes (#104).
238
+ _DYNAMIC_CALL = re.compile(rb"(?<![\w$])(dynami)c(?=\s*\()")
239
+
240
+
241
+ def _keyword_calls(src: bytes) -> tuple[bytes, int]:
242
+ if b"dynamic" not in src:
243
+ return src, 0
244
+ return _DYNAMIC_CALL.subn(rb"\1C", src)
245
+
246
+
247
+ class KFile:
248
+ def __init__(self, rel: str, src: bytes, tree):
249
+ self.rel, self.src, self.tree = rel, src, tree
250
+ self.package = ""
251
+ self.imports: dict[str, str] = {} # short name -> fqn
252
+ self.star: list[str] = []
253
+ self.test = bool(TEST_PATH.search(rel))
254
+ m = SOURCE_SET.search(rel)
255
+ self.source_set = m.group(2) if m else None
256
+ self.source_set_dir = m.group(1) if m else None
257
+
258
+
259
+ # mutating calls on a stored MutableList / MutableSet / MutableMap / StateFlow field: `items.add(x)` writes `items`
260
+ KOTLIN_MUTATING = frozenset({
261
+ "add", "addAll", "remove", "removeAll", "removeAt", "removeIf", "removeFirst", "removeLast", "retainAll", "clear",
262
+ "put", "putAll", "set", "getOrPut", "compute", "computeIfAbsent", "merge", "sort", "sortBy", "sortWith",
263
+ "sortByDescending", "shuffle", "reverse", "fill", "update"})
264
+
265
+
266
+ class KotlinPlugin(LanguagePlugin):
267
+ name = "kotlin"
268
+
269
+ def detect(self, project: Project) -> bool:
270
+ self._files = source_files(project.root, project)
271
+ return any(f.endswith(".kt") for f in self._files)
272
+
273
+ def t(self, n) -> str:
274
+ return self.cur.src[n.start_byte:n.end_byte].decode("utf-8", "replace")
275
+
276
+ # ------------------------------------------------------------------ index
277
+ def index(self, project: Project, builder: GraphBuilder, frameworks) -> dict:
278
+ t0 = time.time()
279
+ p = parser()
280
+ self.b = builder
281
+ files = getattr(self, "_files", None)
282
+ if files is None:
283
+ files = source_files(project.root, project)
284
+ self.decls: dict[str, Decl] = {}
285
+ self.test_fw: dict[str, str | None] = {}
286
+ self._fd = None
287
+ self.by_name: dict[str, list[Decl]] = defaultdict(list)
288
+ self.classes: dict[str, Decl] = {}
289
+ self.class_short: dict[str, list[Decl]] = defaultdict(list)
290
+ self.members: dict[str, dict[str, list[Decl]]] = defaultdict(lambda: defaultdict(list))
291
+ self.calls: list[tuple] = [] # (owner_id, name, receiver, line, file obj, decl)
292
+ self.call_branch: dict = {} # (owner_id, line, name) -> {branch, branch_line} (#88)
293
+ # properties with a custom accessor, `by lazy` or a delegate are nodes (#89); kept out of by_name / members so
294
+ # a call `x()` never binds to one
295
+ self.props: dict[str, dict] = {} # decl id -> {"property", "accessors"}
296
+ self.props_by_name: dict[str, list[Decl]] = defaultdict(list)
297
+ self.prop_members: dict[str, dict[str, list[Decl]]] = defaultdict(lambda: defaultdict(list))
298
+ self.prop_at: dict[tuple, Decl] = {} # (file, start byte) -> decl
299
+ self.stored_names: set[str] = set() # plain stored properties / ctor vals
300
+ # stored properties of a class / enum are `field:<Type>.<name>` nodes with READS_PROP / WRITES_PROP (#88)
301
+ self.fields: dict[str, dict[str, str]] = defaultdict(dict) # class fqn -> name -> field id
302
+ self.field_names: set[str] = set()
303
+ self.copies: list[tuple] = [] # (owner, receiver (type, text), [(arg name, line)], kf, decl): `x.copy(a = 1)`
304
+ self.frefs: list[tuple] = [] # (owner_id, name, receiver (type, text) | None, line, file, decl, write)
305
+ self.reads: list[tuple] = [] # (owner_id, name, receiver node, line, file obj, decl, read | write)
306
+ self.http: list[dict] = []
307
+ self.navs: list[tuple] = []
308
+ self.base_urls: set[str] = set()
309
+ self.api_base: dict[str, set] = defaultdict(set) # Retrofit interface short name -> base URLs it is built with
310
+ self.build_config = self._build_config(project.root, files)
311
+ self.st = defaultdict(int)
312
+ self.values: dict[str, dict[str, str]] = defaultdict(dict) # owner fqn / pkg:<package> -> name -> id
313
+ self.value_fq: dict[str, str] = {} # fqn -> id (imports name them)
314
+ self.const_str: dict[str, tuple] = {} # constant id -> (string literal source, KFile, owner Decl) (#67)
315
+ kfiles, failed = [], []
316
+ errs: dict[str, list] = {}
317
+ for rel in files:
318
+ try:
319
+ src = (project.root / rel).read_bytes()
320
+ except OSError:
321
+ failed.append(rel)
322
+ continue
323
+ psrc, n_susp = _suspend_lambdas(src)
324
+ if n_susp:
325
+ self.st["suspend_lambdas_rewritten"] += n_susp
326
+ self.st["files_with_suspend_lambdas"] += 1
327
+ psrc, n_route = _route_calls(psrc)
328
+ if n_route:
329
+ self.st["accessor_like_calls_rewritten"] += n_route
330
+ psrc, n_kw = _keyword_calls(psrc)
331
+ if n_kw:
332
+ self.st["keyword_named_calls_rewritten"] += n_kw
333
+ tree = p.parse(psrc)
334
+ dropped: list = []
335
+ if tree.root_node.has_error:
336
+ # #104: parse member by member; a member that still errors is blanked, the rest keep their nodes
337
+ nt, dropped, _ = reparse_members(p, psrc, tree)
338
+ if nt is not None:
339
+ tree = nt
340
+ self.st["files_reparsed_by_member"] += 1
341
+ self.st["members_dropped_by_reparse"] += len(dropped)
342
+ kf = KFile(rel, src, tree) # names and text from the original bytes; offsets are unchanged
343
+ if tree.root_node.has_error or dropped:
344
+ self.st["files_with_syntax_errors"] += 1
345
+ errs[rel] = merge(tree_spans(tree.root_node) + [list(x) for x in dropped])
346
+ kfiles.append(kf)
347
+ for kf in kfiles: # pass 1: declarations
348
+ self.cur = kf
349
+ self._header(kf)
350
+ if kf.test:
351
+ self.test_fw[kf.rel] = self._test_framework(kf)
352
+ self._decls(kf.tree.root_node, kf, None, None)
353
+ self._data_models(kfiles)
354
+ for kf in kfiles: # pass 2: references and framework facts
355
+ self.cur = kf
356
+ fid = self._file_node(kf)
357
+ self._refs(kf.tree.root_node, kf, fid, None, {"prefix": "", "guards": [], "routing": False})
358
+ if kf.source_set and kf.source_set in SET_PLATFORM:
359
+ self._mark_platform(kf)
360
+ self._resolve_calls()
361
+ self._hierarchy()
362
+ self._security_rules(kfiles)
363
+ self._emit_http()
364
+ self._link_navs()
365
+ self._manifests(project)
366
+ self.file_report = {"seen": [kf.rel for kf in kfiles] + failed, "parse_failed": failed, "syntax_errors": errs}
367
+ mode = self._exact(project, files)
368
+ st = dict(self.st)
369
+ st.update({"mode": mode, "files": len(kfiles), "kt_files": sum(1 for kf in kfiles if kf.rel.endswith(".kt")),
370
+ "declarations": len(self.decls),
371
+ "seconds": round(time.time() - t0, 2)})
372
+ return st
373
+
374
+ def _exact(self, project: Project, files: list[str]) -> str:
375
+ """The scip-java layer when an index is available (codegraph/plugins/kotlin/exact.py), else heuristic."""
376
+ from .exact import ExactLayer, find_index, skipped_modules
377
+ t1 = time.time()
378
+ path, info = find_index(project, [f for f in files if f.endswith(EXTS)])
379
+ mode = "heuristic"
380
+ if path is not None:
381
+ sst: dict = {}
382
+ try:
383
+ layer = ExactLayer(self)
384
+ if layer.apply(path, sst):
385
+ mode = "scip"
386
+ sk = skipped_modules(info.get("android_modules"), layer.doc_paths)
387
+ if sk:
388
+ info["skipped_modules"] = sk
389
+ else:
390
+ info["status"] = "the SCIP index has no Kotlin documents"
391
+ except Exception as e: # a corrupt / foreign index must not lose the heuristic graph
392
+ info["status"] = f"SCIP import failed ({type(e).__name__}: {e})"
393
+ for k in ("exact_vs_heuristic", "scip_documents", "scip_files", "scip_defs_matched", "scip_defs_unmatched",
394
+ "scip_references", "scip_refs_external", "java"):
395
+ if k in sst:
396
+ self.st[k] = sst[k]
397
+ if sst.get("scip_warning"):
398
+ info["warning"] = sst["scip_warning"]
399
+ info["seconds"] = round(time.time() - t1, 2)
400
+ self.st["scip"] = info
401
+ return mode
402
+
403
+ def _file_node(self, kf: KFile) -> str:
404
+ return self.b.add_node("file", f"kotlin:{kf.rel}", name=kf.rel, file=kf.rel, line=1, lang="kotlin",
405
+ module=kf.package or None, attrs={"test": True} if kf.test else {})
406
+
407
+ def _header(self, kf: KFile):
408
+ for c in kf.tree.root_node.children:
409
+ if c.type == "package_header":
410
+ q = next((x for x in c.children if x.type == "qualified_identifier"), None)
411
+ kf.package = self.t(q) if q else ""
412
+ elif c.type == "import":
413
+ q = next((x for x in c.children if x.type == "qualified_identifier"), None)
414
+ if q is None:
415
+ continue
416
+ fq = self.t(q)
417
+ alias = next((self.t(x) for x in c.children if x.type == "identifier"), None)
418
+ if self.t(c).rstrip().endswith("*"):
419
+ kf.star.append(fq)
420
+ else:
421
+ kf.imports[alias or fq.rsplit(".", 1)[-1]] = fq
422
+ elif c.type == "imports" or c.type == "import_list":
423
+ for x in c.children:
424
+ if x.type == "import":
425
+ q = next((y for y in x.children if y.type == "qualified_identifier"), None)
426
+ if q is not None:
427
+ fq = self.t(q)
428
+ kf.imports[fq.rsplit(".", 1)[-1]] = fq
429
+
430
+ def _test_framework(self, kf: KFile) -> str | None:
431
+ """The test framework a test file's `@Test` comes from: the explicit `Test` import, else a star import, else
432
+ the first framework import in the file (JUnit 5 > JUnit 4 > kotlin.test > TestNG)."""
433
+ fq = kf.imports.get("Test") or kf.imports.get("ParameterizedTest") or ""
434
+ cands = [fq] if fq else []
435
+ cands += [f + ".*" for f in kf.star]
436
+ cands += list(kf.imports.values())
437
+ for c in cands:
438
+ for prefix, fw in TEST_FRAMEWORKS:
439
+ if c.startswith(prefix):
440
+ return fw
441
+ return None
442
+
443
+ # ------------------------------------------------------------------ pass 1
444
+ def _annotations(self, n) -> tuple[list, set]:
445
+ anns, mods = [], set()
446
+ # tree-sitter-kotlin quirk: annotations right after the package header (no imports) parse as a sibling
447
+ # annotated_expression in front of the declaration
448
+ prev = n.prev_named_sibling
449
+ if prev is not None and prev.type == "annotated_expression" and n.start_point[0] - prev.end_point[0] <= 1:
450
+ for m in re.finditer(r'@([\w.]+)(\s*\((?:[^()]|\([^()]*\))*\))?', self.t(prev)):
451
+ s = re.search(r'"((?:[^"\\]|\\.)*)"', m.group(2) or "")
452
+ anns.append((m.group(1).split(".")[-1], s.group(1) if s else None, m.group(0)))
453
+ for c in n.children:
454
+ if c.type != "modifiers":
455
+ continue
456
+ for m in c.children:
457
+ if m.type == "annotation":
458
+ txt = self.t(m)
459
+ mm = re.match(r"@(?:[\w]+:)?([\w.]+)", txt)
460
+ nm = mm.group(1).split(".")[-1] if mm else txt
461
+ s = re.search(r'"((?:[^"\\]|\\.)*)"', txt)
462
+ anns.append((nm, s.group(1) if s else None, txt))
463
+ else:
464
+ mods.update(self.t(m).split())
465
+ return anns, mods
466
+
467
+ def _name_node(self, n):
468
+ x = n.child_by_field_name("name")
469
+ if x is None:
470
+ x = next((c for c in n.children if c.type in ("identifier", "type_identifier", "simple_identifier")), None)
471
+ return x
472
+
473
+ def _name(self, n) -> str | None:
474
+ x = self._name_node(n)
475
+ return self.t(x) if x is not None else None
476
+
477
+ def _name_line(self, n) -> int:
478
+ x = self._name_node(n)
479
+ return (x if x is not None else n).start_point[0] + 1
480
+
481
+ def _decls(self, n, kf: KFile, cls: Decl | None, fn: Decl | None):
482
+ for c in n.children:
483
+ ty = c.type
484
+ if ty in ("class_declaration", "object_declaration", "companion_object"):
485
+ nm = self._name(c) or ("Companion" if ty == "companion_object" else None)
486
+ if not nm:
487
+ self._decls(c, kf, cls, fn)
488
+ continue
489
+ fq = f"{cls.fqn}.{nm}" if cls else (f"{kf.package}.{nm}" if kf.package else nm)
490
+ anns, mods = self._annotations(c)
491
+ head = self.t(c).split("{", 1)[0]
492
+ kk = "interface" if re.search(r"\binterface\b", head) else ("object" if ty != "class_declaration" else
493
+ ("enum" if "enum" in mods else "class"))
494
+ supers = []
495
+ for d in c.children:
496
+ if d.type == "delegation_specifiers":
497
+ for ut in d.children:
498
+ m = re.match(r"\s*([\w.]+)", self.t(ut))
499
+ if m:
500
+ supers.append(m.group(1).split(".")[-1])
501
+ key = fq + self._variant(kf, mods)
502
+ types = {}
503
+ for d in c.children:
504
+ if d.type == "primary_constructor":
505
+ types.update(self._params(d))
506
+ dc = Decl(f"class:{key}", "class", nm, fq, kf.rel, c.start_point[0] + 1, c.end_point[0] + 1,
507
+ cls.fqn if cls else None, supers, anns, mods, types=types, test=kf.test,
508
+ name_line=self._name_line(c))
509
+ self._add_decl(dc, kk)
510
+ body = next((d for d in c.children if d.type in ("class_body", "enum_class_body")), None)
511
+ if kk in ("class", "enum"):
512
+ for d in c.children:
513
+ if d.type == "primary_constructor":
514
+ for cp in d.children:
515
+ for q in (cp.children if cp.type == "class_parameters" else []):
516
+ kw = next((self.t(x) for x in q.children if x.type in ("val", "var")), None)
517
+ ids = [x for x in q.children if x.type == "identifier"]
518
+ if q.type == "class_parameter" and kw and ids:
519
+ self._field(dc, self.t(ids[0]), kw, kf, q)
520
+ if body is not None:
521
+ for d in body.children:
522
+ if d.type == "property_declaration":
523
+ dc.types.update(self._prop_type(d))
524
+ if not self._prop_kind(d):
525
+ nm_ = self._name(next((x for x in d.children if x.type == "variable_declaration"), d))
526
+ if nm_:
527
+ self.stored_names.add(nm_)
528
+ kw = next((self.t(x).split()[-1] for x in d.children if x.type == "binding_pattern_kind"
529
+ or self.t(x) in ("val", "var")), "val")
530
+ if kk in ("class", "enum") and not self._is_constant(d, dc):
531
+ self._field(dc, nm_, kw, kf, d)
532
+ self._decls(body, kf, dc, None)
533
+ self.stored_names.update(types)
534
+ elif ty == "function_declaration":
535
+ nm = self._name(c)
536
+ if not nm:
537
+ continue
538
+ anns, mods = self._annotations(c)
539
+ recv = None
540
+ for d in c.children:
541
+ if d.type == "identifier":
542
+ break
543
+ if d.type in ("user_type", "nullable_type"):
544
+ recv = self.t(d).split("<")[0].rstrip("?")
545
+ owner = cls.fqn if cls else (kf.package or "")
546
+ fq = f"{owner}.{nm}" if owner else nm
547
+ kind = "method" if cls else "function"
548
+ params = next((d for d in c.children if d.type == "function_value_parameters"), None)
549
+ dc = Decl(f"{kind}:{fq}{self._variant(kf, mods)}", kind, nm, fq, kf.rel, c.start_point[0] + 1,
550
+ c.end_point[0] + 1, cls.fqn if cls else None, [], anns, mods, recv,
551
+ self._params(params) if params is not None else {}, kf.test, self._name_line(c))
552
+ self._local_types(c, dc.types)
553
+ self._add_decl(dc, kind)
554
+ elif ty == "enum_entry" and cls is not None:
555
+ nm = self._name(c)
556
+ if nm:
557
+ self._value(cls, "enum_case", nm, kf, c)
558
+ body = next((d for d in c.children if d.type == "class_body"), None)
559
+ if body is not None: # `GREEN { override fun label() = "g" }`: the enum's members
560
+ self._decls(body, kf, cls, fn)
561
+ elif ty == "property_declaration" and fn is None and self._prop_kind(c):
562
+ self._prop_decl(c, kf, cls)
563
+ elif ty == "property_declaration" and fn is None and self._is_constant(c, cls):
564
+ nm = self._name(next((d for d in c.children if d.type == "variable_declaration"), c))
565
+ if nm:
566
+ nid = self._value(cls, "constant", nm, kf, c)
567
+ eq = next((i for i, x in enumerate(c.children) if x.type == "="), None)
568
+ init = c.children[eq + 1] if eq is not None and eq + 1 < len(c.children) else None
569
+ if init is not None and init.type == "string_literal":
570
+ self.const_str[nid] = (self.t(init), kf, cls)
571
+ elif ty in ("property_declaration",) and cls is None:
572
+ pass
573
+ else:
574
+ self._decls(c, kf, cls, fn)
575
+
576
+ def _field(self, cls: Decl, nm: str, kw: str, kf: KFile, n):
577
+ """A stored property of a class (`var items = ...`, a constructor `val owner`): `field:<Type>.<name>` (#88)."""
578
+ if nm in self.fields[cls.fqn]:
579
+ return
580
+ a = {"property": "stored", "binding": "var" if kw.endswith("var") else "val"}
581
+ if kf.test:
582
+ a["test"] = True
583
+ fid = self.b.add_node("field", f"{cls.fqn}.{nm}", name=nm, fqn=f"{cls.fqn}.{nm}", file=kf.rel,
584
+ line=n.start_point[0] + 1, end_line=n.end_point[0] + 1,
585
+ module=getattr(self.b.nodes.get(cls.id), "module", None), lang="kotlin", attrs=a)
586
+ self.b.add_edge(cls.id, fid, "CONTAINS", kf.rel, n.start_point[0] + 1, EXACT)
587
+ self.fields[cls.fqn][nm] = fid
588
+ self.field_names.add(nm)
589
+ self.st["stored_property_nodes"] += 1
590
+
591
+ def _prop_kind(self, c) -> tuple | None:
592
+ """(property kind, accessors) of a property that runs code when read or written (#89): a custom `get()` /
593
+ `set(value)` (custom), `by lazy { }` (lazy) or another delegate (delegated); None for a stored property."""
594
+ acc = []
595
+ for x in c.children:
596
+ if x.type == "getter":
597
+ acc.append("get")
598
+ elif x.type == "setter":
599
+ acc.append("set")
600
+ elif x.type == "property_delegate":
601
+ acc.append("lazy" if re.match(r"by\s+lazy\b", self.t(x)) else "delegate")
602
+ if not acc:
603
+ return None
604
+ kind = "custom" if ("get" in acc or "set" in acc) else ("lazy" if "lazy" in acc else "delegated")
605
+ return kind, acc
606
+
607
+ def _prop_decl(self, c, kf: KFile, cls: Decl | None):
608
+ """`method:<Type>.<name>` (in a class), `function:<package>.<name>` (top level; an extension property keeps
609
+ its receiver, as extension functions do), kotlin_kind property."""
610
+ v = next((x for x in c.children if x.type == "variable_declaration"), None)
611
+ nm = self._name(v) if v is not None else None
612
+ if not nm:
613
+ return
614
+ kind, acc = self._prop_kind(c)
615
+ recv = None
616
+ for d in c.children:
617
+ if d.type == "variable_declaration":
618
+ break
619
+ if d.type in ("user_type", "nullable_type"):
620
+ recv = self.t(d).split("<")[0].rstrip("?")
621
+ anns, mods = self._annotations(c)
622
+ owner = cls.fqn if cls else (kf.package or "")
623
+ fq = f"{owner}.{nm}" if owner else nm
624
+ k = "method" if cls else "function"
625
+ types = {}
626
+ ty = next((x for x in v.children if x.type in ("user_type", "nullable_type")), None)
627
+ dc = Decl(f"{k}:{fq}{self._variant(kf, mods)}", k, nm, fq, kf.rel, c.start_point[0] + 1, c.end_point[0] + 1,
628
+ cls.fqn if cls else None, [], anns, mods, recv, types, kf.test, self._name_line(v))
629
+ if dc.id in self.decls:
630
+ return
631
+ self.props[dc.id] = {"property": kind, "accessors": acc,
632
+ "type": self.t(ty).split("<")[0].rstrip("?").split(".")[-1] if ty is not None else None}
633
+ self._add_decl(dc, "property", prop=True)
634
+ self.b.nodes[dc.id].attrs.update(property=kind, accessors=acc)
635
+ self.props_by_name[nm].append(dc)
636
+ if dc.cls:
637
+ self.prop_members[dc.cls][nm].append(dc)
638
+ self.prop_at[(kf.rel, c.start_byte)] = dc
639
+ self.st["property_nodes"] += 1
640
+
641
+ def _is_constant(self, c, cls: Decl | None) -> bool:
642
+ """`const val` anywhere; a `val` / `var` of an `object` or `companion object`; a file-level `val` (#84). No
643
+ custom getter (that is computed each read)."""
644
+ if any(x.type == "getter" for x in c.children) or re.search(r"\bget\s*\(", self.t(c).split("=", 1)[0]):
645
+ return False
646
+ _anns, mods = self._annotations(c)
647
+ if "const" in mods:
648
+ return True
649
+ if cls is None:
650
+ vb = next((x for x in c.children if x.type in ("val", "var") or self.t(x) in ("val", "var")), None)
651
+ return vb is not None and self.t(vb) == "val" and any(x.type == "=" for x in c.children)
652
+ return self.b.nodes.get(cls.id) is not None and \
653
+ self.b.nodes[cls.id].attrs.get("kotlin_kind") == "object"
654
+
655
+ def _value(self, cls: Decl | None, kind: str, nm: str, kf: KFile, n):
656
+ """An enum entry (`enum_case:<Enum>.<NAME>`) or a constant (`constant:<Type>.<NAME>`; a companion object's
657
+ is its class's, as code names it: `K.A`; `constant:<package>.<NAME>` at file level): the node USES_VALUE
658
+ edges point at (#84). Not a call target."""
659
+ owner = cls
660
+ if cls is not None and cls.name == "Companion" and cls.cls:
661
+ owner = self.classes.get(cls.cls) or cls
662
+ ofq = owner.fqn if owner is not None else (kf.package or "")
663
+ fq = f"{ofq}.{nm}" if ofq else nm
664
+ key = f"{fq}@{cls.id.split('@', 1)[1]}" if cls is not None and "@" in cls.id else fq
665
+ line = n.start_point[0] + 1
666
+ nid = self.b.add_node(kind, key, name=nm, fqn=fq, file=kf.rel, line=line, end_line=n.end_point[0] + 1,
667
+ module=(fq.rsplit(".", 1)[0] if "." in fq else None), lang="kotlin",
668
+ attrs={"test": True} if kf.test else {})
669
+ if cls is not None:
670
+ self.b.add_edge(cls.id, nid, "CONTAINS", kf.rel, line, EXACT)
671
+ self.values[owner.fqn if owner is not None else f"pkg:{kf.package}"].setdefault(nm, nid)
672
+ self.value_fq.setdefault(fq, nid)
673
+ return nid
674
+
675
+ def _const_id(self, expr: str, kf: KFile, cls: Decl | None) -> str | None:
676
+ """The constant `NAME` / `Obj.NAME` / `a.b.Obj.NAME` names, seen from a class (its own and its companion's
677
+ constants) in file kf: imports, then the file's package."""
678
+ expr = expr.strip()
679
+ if not re.fullmatch(r"[A-Za-z_][\w.]*", expr):
680
+ return None
681
+ if "." in expr:
682
+ tgt, nm = expr.rsplit(".", 1)
683
+ t = self.classes.get(tgt) or (self._class_of(tgt, kf) if "." not in tgt and tgt[:1].isupper() else None)
684
+ if t is not None:
685
+ if t.name == "Companion" and t.cls:
686
+ t = self.classes.get(t.cls) or t
687
+ return self.values.get(t.fqn, {}).get(nm)
688
+ return self.value_fq.get(expr)
689
+ c = cls
690
+ while c is not None: # own constants, then those of the enclosing classes
691
+ if c.name == "Companion" and c.cls:
692
+ c = self.classes.get(c.cls) or c
693
+ vid = self.values.get(c.fqn, {}).get(expr)
694
+ if vid is not None:
695
+ return vid
696
+ c = self.classes.get(c.cls) if c.cls else None
697
+ fq = kf.imports.get(expr)
698
+ if fq:
699
+ return self.value_fq.get(fq)
700
+ return self.values.get(f"pkg:{kf.package}", {}).get(expr)
701
+
702
+ def _expand(self, raw: str, kf: KFile, cls: Decl | None, depth: int = 0) -> str:
703
+ """A string literal's source with the `$NAME` / `${Obj.NAME}` of string constants put in (#67:
704
+ `const val TASKS_ROUTE = "$TASKS_SCREEN?$ARG={$ARG}"`); other templates stay for template()."""
705
+ def rep(m):
706
+ expr = m.group(1) if m.group(1) is not None else m.group(2)
707
+ vid = self._const_id(expr, kf, cls) if depth < 4 else None
708
+ got = self.const_str.get(vid) if vid else None
709
+ if got is None:
710
+ return m.group(0)
711
+ body = self._expand(got[0], got[1], got[2], depth + 1)
712
+ return body[3:-3] if body.startswith('"""') else body[1:-1]
713
+ return TEMPLATE.sub(rep, raw)
714
+
715
+ def _route_arg(self, args, kf: KFile, decl: Decl | None, named: str | None = None) -> str | None:
716
+ """The route string of `composable(...)` / `navigate(...)`: a string literal (constants in it expanded) or a
717
+ string constant (`Destinations.TASKS_ROUTE`), positional or `route = ...`."""
718
+ if args is None:
719
+ return None
720
+ x = None
721
+ for a in args.children:
722
+ if a.type != "value_argument":
723
+ continue
724
+ kids = [k for k in a.children if k.is_named]
725
+ if a.children and any(k.type == "=" for k in a.children):
726
+ if named and kids and self.t(kids[0]) == named and len(kids) > 1:
727
+ x = kids[-1]
728
+ break
729
+ continue
730
+ if x is None and kids:
731
+ x = kids[0]
732
+ if not named:
733
+ break
734
+ if x is None:
735
+ return None
736
+ cls = self.classes.get(decl.cls) if decl is not None and decl.kind != "class" and decl.cls else \
737
+ (decl if decl is not None and decl.kind == "class" else None)
738
+ if x.type == "string_literal":
739
+ return template(self._expand(self.t(x), kf, cls))
740
+ if x.type in ("identifier", "simple_identifier", "navigation_expression"):
741
+ vid = self._const_id(self.t(x), kf, cls)
742
+ got = self.const_str.get(vid) if vid else None
743
+ if got is not None:
744
+ self.st["routes_from_constants"] += 1
745
+ return template(self._expand(got[0], got[1], got[2]))
746
+ return None
747
+
748
+ def _variant(self, kf: KFile, mods: set) -> str:
749
+ return f"@{kf.source_set}" if "actual" in mods and kf.source_set else ""
750
+
751
+ def _params(self, n) -> dict:
752
+ out = {}
753
+ for p in (n.children if n is not None else []):
754
+ if p.type in ("parameter", "class_parameter", "class_parameters", "function_value_parameter"):
755
+ if p.type == "class_parameters":
756
+ out.update(self._params(p))
757
+ continue
758
+ ids = [c for c in p.children if c.type == "identifier"]
759
+ ty = next((c for c in p.children if c.type in ("user_type", "nullable_type")), None)
760
+ if ids and ty is not None:
761
+ out[self.t(ids[0])] = self.t(ty).split("<")[0].rstrip("?").split(".")[-1]
762
+ return out
763
+
764
+ def _local_types(self, fn, types: dict):
765
+ """`val repo = OrderRepository(..)` / `val api: OrdersApi = ..` in a function body (not in nested classes or
766
+ functions): the local's type, so `repo.save()` binds through it (#96). Parameters win; flow-insensitive."""
767
+ body = next((c for c in fn.children if c.type == "function_body"), None)
768
+ stack = list(body.children) if body is not None else []
769
+ while stack:
770
+ n = stack.pop()
771
+ if n.type in ("class_declaration", "object_declaration", "function_declaration"):
772
+ continue
773
+ if n.type == "property_declaration":
774
+ for k, v in self._prop_type(n).items():
775
+ types.setdefault(k, v)
776
+ stack.extend(n.children)
777
+
778
+ def _prop_type(self, d) -> dict:
779
+ v = next((c for c in d.children if c.type == "variable_declaration"), None)
780
+ if v is None:
781
+ return {}
782
+ ids = [c for c in v.children if c.type == "identifier"]
783
+ ty = next((c for c in v.children if c.type in ("user_type", "nullable_type")), None)
784
+ if ids and ty is not None:
785
+ return {self.t(ids[0]): self.t(ty).split("<")[0].rstrip("?").split(".")[-1]}
786
+ # val api = Retrofit...create(OrdersApi::class.java) / val repo = OrderRepository(...)
787
+ txt = self.t(d)
788
+ m = re.search(r"create\(\s*(\w+)::class", txt) or re.match(r"[^=]*=(?!=)\s*([A-Z]\w*)\s*\(", txt) \
789
+ or re.search(r"\bby\s+lazy\s*(?:\([^)]*\))?\s*\{\s*([A-Z]\w*)\s*\(", txt) \
790
+ or re.match(r"[^=]*=(?!=)\s*(?:mockk|spyk|mock|spy)\s*<\s*([A-Z]\w*)", txt) # = mockk<Repo> { .. }
791
+ if ids and m:
792
+ return {self.t(ids[0]): m.group(1)}
793
+ return {}
794
+
795
+ def _add_decl(self, d: Decl, display_kind: str, prop: bool = False):
796
+ attrs = {"kotlin_kind": display_kind}
797
+ if d.test:
798
+ attrs["test"] = True
799
+ for a in ("expect", "actual", "suspend", "override", "abstract", "data", "sealed"):
800
+ if a in d.modifiers:
801
+ attrs[a] = True
802
+ if d.receiver:
803
+ attrs["receiver"] = d.receiver
804
+ if d.annotations:
805
+ attrs["annotations"] = [a[0] for a in d.annotations][:12]
806
+ pkg = d.fqn.rsplit(".", 1)[0] if "." in d.fqn else ""
807
+ self.b.add_node(d.kind, d.id.split(":", 1)[1], name=d.name, fqn=d.fqn, file=d.file, line=d.line, end_line=d.end,
808
+ module=pkg or None, lang="kotlin", attrs=attrs)
809
+ if d.test and d.kind in ("function", "method") and any(a[0] in TEST_ANNOTATIONS for a in d.annotations):
810
+ n = self.b.nodes[d.id]
811
+ n.entry_kind = "test"
812
+ fw = self.test_fw.get(d.file)
813
+ if fw:
814
+ n.attrs["framework"] = fw
815
+ if any(a[0] == "ParameterizedTest" for a in d.annotations):
816
+ n.attrs["parameterized"] = True
817
+ self.decls[d.id] = d
818
+ if prop:
819
+ if d.cls:
820
+ self.b.add_edge(f"class:{d.cls}", d.id, "CONTAINS", d.file, d.line, EXACT)
821
+ return
822
+ self.by_name[d.name].append(d)
823
+ if d.kind == "class":
824
+ if "expect" in d.modifiers:
825
+ self.classes[d.fqn] = d # common code names the `expect` class, not the actual of whichever source
826
+ else: # set happened to be read first (#86)
827
+ self.classes.setdefault(d.fqn, d)
828
+ self.class_short[d.name].append(d)
829
+ if d.cls:
830
+ self.members[d.cls][d.name].append(d)
831
+ self.b.add_edge(f"class:{d.cls}", d.id, "CONTAINS", d.file, d.line, EXACT)
832
+ self._decl_facts(d)
833
+
834
+ def _decl_facts(self, d: Decl):
835
+ names = {a[0] for a in d.annotations}
836
+ if d.kind != "class":
837
+ if "Scheduled" in names:
838
+ self.b.nodes[d.id].entry_kind = "scheduled"
839
+ self.st["scheduled"] += 1
840
+ elif names & LISTENERS:
841
+ self.b.nodes[d.id].entry_kind = "listener"
842
+ self.st["listeners"] += 1
843
+
844
+ # ------------------------------------------------------------------ pass 2
845
+ def _owner_at(self, kf: KFile, line: int) -> Decl | None:
846
+ best = None
847
+ for d in self._file_decls(kf):
848
+ if d.kind != "class" and d.line <= line <= d.end and (best is None or d.end - d.line < best.end - best.line):
849
+ best = d
850
+ return best
851
+
852
+ def _file_decls(self, kf: KFile):
853
+ fd = getattr(self, "_fd", None)
854
+ if fd is None:
855
+ fd = self._fd = defaultdict(list)
856
+ for d in self.decls.values():
857
+ fd[d.file].append(d)
858
+ return fd.get(kf.rel, ())
859
+
860
+ def _decl_at(self, kf: KFile, n, kinds) -> Decl | None:
861
+ line = n.start_point[0] + 1
862
+ for d in self._file_decls(kf):
863
+ if d.line == line and d.kind in kinds and d.name == self._name(n):
864
+ return d
865
+ return None
866
+
867
+ def _refs(self, n, kf: KFile, owner: str, decl: Decl | None, ctx: dict):
868
+ for c in n.children:
869
+ ty = c.type
870
+ if self.values and ty in ("navigation_expression", "identifier"):
871
+ self._value_ref(c, kf, owner, decl)
872
+ if (self.props_by_name or self.field_names) and ty in ("navigation_expression", "identifier"):
873
+ self._prop_ref(c, kf, owner, decl)
874
+ if ty == "property_declaration" and (kf.rel, c.start_byte) in self.prop_at:
875
+ d = self.prop_at[(kf.rel, c.start_byte)]
876
+ self._refs(c, kf, d.id, d, {"prefix": "", "guards": [], "routing": False})
877
+ continue
878
+ if ty == "function_declaration":
879
+ d = self._decl_at(kf, c, ("function", "method"))
880
+ if d is not None:
881
+ self._fn_facts(d)
882
+ rctx = {"prefix": "", "guards": [], "routing": (d.receiver or "").split(".")[-1] in ("Route", "Routing")}
883
+ self._refs(c, kf, d.id, d, rctx)
884
+ continue
885
+ elif ty in ("class_declaration", "object_declaration", "companion_object"):
886
+ d = self._decl_at(kf, c, ("class",)) if ty != "companion_object" else None
887
+ self._refs(c, kf, d.id if d else owner, d or decl, ctx)
888
+ continue
889
+ elif ty == "call_expression":
890
+ self._copy_call(c, kf, owner, decl)
891
+ if self._call(c, kf, owner, decl, ctx):
892
+ continue
893
+ elif ty == "binary_expression":
894
+ m = re.match(r"(composable|entry|dialog|(?:get|post|put|delete|patch|head|options))\s*<\s*([\w.]+)\s*>",
895
+ self.t(c))
896
+ lam = next((x for x in c.children if x.type in ("lambda_literal", "annotated_lambda")), None)
897
+ if m and lam is not None and self._typed_block(m.group(1), m.group(2), c, lam, kf, owner, decl, ctx):
898
+ continue
899
+ self._refs(c, kf, owner, decl, ctx)
900
+
901
+ def _call(self, c, kf: KFile, owner: str, decl: Decl | None, ctx: dict) -> bool:
902
+ """Record one call; returns True when it walked the children itself (route / page lambdas)."""
903
+ line = c.start_point[0] + 1
904
+ callee = c.children[0] if c.children else None
905
+ lam = next((x for x in c.children if x.type == "annotated_lambda"), None)
906
+ args = next((x for x in c.children if x.type == "value_arguments"), None)
907
+ targs = None
908
+ if callee is not None and callee.type == "call_expression" and lam is not None: # f("x") { ... }
909
+ args = next((x for x in callee.children if x.type == "value_arguments"), None)
910
+ targs = next((x for x in callee.children if x.type == "type_arguments"), None)
911
+ callee = callee.children[0] if callee.children else None
912
+ if callee is None:
913
+ return False
914
+ recv, name = None, None
915
+ if callee.type == "identifier":
916
+ name = self.t(callee)
917
+ elif callee.type == "navigation_expression":
918
+ ids = callee.children
919
+ name = self.t(ids[-1]) if ids and ids[-1].type == "identifier" else None
920
+ recv = ids[0] if ids else None
921
+ if not name:
922
+ return False
923
+ sarg = self._first_string(args)
924
+ rtext = self.t(recv) if recv is not None else None
925
+ # ---- Ktor server routing
926
+ if lam is not None and recv is None:
927
+ if name == "routing":
928
+ self._refs(lam, kf, owner, decl, {"prefix": ctx["prefix"], "guards": ctx["guards"], "routing": True})
929
+ return True
930
+ if ctx.get("routing") and name == "route" and sarg is not None:
931
+ self._refs(lam, kf, owner, decl, {**ctx, "prefix": join_path(ctx["prefix"], template(sarg))})
932
+ return True
933
+ if ctx.get("routing") and name == "authenticate":
934
+ g = f"authenticate({template(sarg) if sarg else ''})"
935
+ self._refs(lam, kf, owner, decl, {**ctx, "guards": ctx["guards"] + [g]})
936
+ return True
937
+ if ctx.get("routing") and name in VERBS:
938
+ uri = join_path(ctx["prefix"], template(sarg) if sarg is not None else "")
939
+ hid = self._ktor_route(kf, name.upper(), uri, c, owner, ctx)
940
+ self._refs(lam, kf, hid, decl, {**ctx, "routing": False})
941
+ return True
942
+ route = self._route_arg(args, kf, decl, "route") if name in ("composable", "dialog") and targs is None else None
943
+ if route is not None:
944
+ pid = self._page(kf, route, c)
945
+ self._refs(lam, kf, pid, decl, ctx)
946
+ return True
947
+ # composable<Route>(deepLinks = ...) { }, Navigation 3 entry<Key>(metadata = ...) { }, Ktor get<Res>(...) { }
948
+ if targs is not None and self._typed_block(name, self.t(targs).strip("<> "), c, lam, kf, owner, decl, ctx):
949
+ return True
950
+ # ---- HTTP clients
951
+ if (name in VERBS or name == "request") and recv is not None and re.search(r"(?i)client|http", rtext or ""):
952
+ first = self._first_arg(args)
953
+ if first is not None and first.type == "string_literal":
954
+ self.http.append({"src": owner, "method": "GET" if name == "request" else name.upper(),
955
+ "url": template(self.t(first)), "client": "ktor", "file": kf.rel, "line": line})
956
+ elif first is None and lam is not None:
957
+ # builder block: client.get { url("...") } / client.request { method = HttpMethod.Post; url { path("...") } }
958
+ body = self.t(lam)
959
+ mu = re.search(r'\burl\s*\(\s*("(?:[^"\\]|\\.)*")', body) or \
960
+ re.search(r'\b(?:path|encodedPath\s*=|appendPathSegments)\s*\(?\s*("(?:[^"\\]|\\.)*")', body)
961
+ mm = re.search(r"\bmethod\s*=\s*HttpMethod\.(\w+)", body)
962
+ if mu:
963
+ verb = mm.group(1).upper() if mm else ("GET" if name == "request" else name.upper())
964
+ self.http.append({"src": owner, "method": verb, "url": template(mu.group(1)), "client": "ktor",
965
+ "file": kf.rel, "line": line})
966
+ if name == "url" and rtext and "Request.Builder" in rtext and sarg is not None:
967
+ whole = c
968
+ while whole.parent is not None and whole.parent.type in ("navigation_expression", "call_expression"):
969
+ whole = whole.parent
970
+ tail = self.t(whole)[len(self.t(c)):]
971
+ m = re.search(r"\.(post|put|delete|patch|head)\s*\(", tail)
972
+ self.http.append({"src": owner, "method": m.group(1).upper() if m else "GET", "url": template(self._raw_first(args)),
973
+ "client": "okhttp", "file": kf.rel, "line": line})
974
+ if name == "baseUrl" and args is not None:
975
+ first = self._first_arg(args)
976
+ raw = sarg
977
+ if raw is None and first is not None:
978
+ bc = re.fullmatch(r"(?:[\w.]+\.)?BuildConfig\.(\w+)", self.t(first).strip())
979
+ vals = self.build_config.get(bc.group(1)) if bc else None
980
+ raw = f'"{vals[0]}"' if vals and len(vals) == 1 else None
981
+ if raw is not None:
982
+ base = template(raw)
983
+ self.base_urls.add(base)
984
+ whole = c # Retrofit.Builder().baseUrl(X)...build().create(Api::class.java)
985
+ while whole.parent is not None and whole.parent.type in ("navigation_expression", "call_expression",
986
+ "value_argument", "value_arguments"):
987
+ whole = whole.parent
988
+ for api in re.findall(r"create\s*\(\s*(\w+)::class", self.t(whole)) + \
989
+ re.findall(r"create<\s*(\w+)\s*>", self.t(whole)):
990
+ self.api_base[api].add(base)
991
+ if name == "navigate" and args is not None:
992
+ first = self._first_arg(args)
993
+ route = self._route_arg(args, kf, decl, "route") if first is not None else None
994
+ if first is not None:
995
+ if route is not None:
996
+ self.navs.append((owner, route, None, kf.rel, line))
997
+ else:
998
+ m = re.match(r"([A-Z]\w*)", self.t(first))
999
+ if m:
1000
+ self.navs.append((owner, None, m.group(1), kf.rel, line))
1001
+ self.calls.append((owner, name, rtext, line, kf, decl, self._accessor(c) if owner in self.props else None))
1002
+ if name[:1].isupper(): # a constructor or a composable: its branch (#88)
1003
+ br = self._branch(c)
1004
+ if br:
1005
+ self.call_branch[(owner, line, name)] = br
1006
+ return False
1007
+
1008
+ def _branch(self, c) -> dict:
1009
+ """The innermost `if` / `else` / `when` entry around a construction or a composable call inside its function
1010
+ (#88): attrs.branch (`if (loading)`, `else of if (loading)`, `when Tab.Home`, `else of when (tab)`) and
1011
+ attrs.branch_line, so a screen that swaps content under a condition shows it on the edge."""
1012
+ x, prev = c.parent, c
1013
+ while x is not None and x.type not in ("function_declaration", "class_body", "source_file",
1014
+ "secondary_constructor", "anonymous_initializer", "getter", "setter"):
1015
+ if x.type == "when_entry":
1016
+ ch = x.children
1017
+ if ch and ch[0].type == "else":
1018
+ w = x.parent
1019
+ subj = next((k for k in w.children if k.type == "when_subject"), None) if w is not None else None
1020
+ lab = "else of when" + (f" {self.t(subj)}" if subj is not None else "")
1021
+ else:
1022
+ conds = []
1023
+ for k in ch:
1024
+ if k.type == "->":
1025
+ break
1026
+ if k.type != ",":
1027
+ conds.append(self.t(k))
1028
+ lab = "when " + ", ".join(conds)
1029
+ if prev is not ch[0]:
1030
+ return {"branch": re.sub(r"\s+", " ", lab)[:80], "branch_line": x.start_point[0] + 1}
1031
+ if x.type == "if_expression":
1032
+ ch = x.children
1033
+ lp = next((i for i, k in enumerate(ch) if k.type == "("), None)
1034
+ rp = next((i for i, k in enumerate(ch) if k.type == ")"), None)
1035
+ cond = " ".join(self.t(k) for k in ch[lp + 1:rp]) if lp is not None and rp is not None else ""
1036
+ els = next((k for k in ch if k.type == "else"), None)
1037
+ inside = rp is not None and prev.start_byte > ch[rp].start_byte
1038
+ if inside:
1039
+ lab = ("else of " if els is not None and prev.start_byte > els.start_byte else "") + f"if ({cond})"
1040
+ return {"branch": re.sub(r"\s+", " ", lab)[:80], "branch_line": x.start_point[0] + 1}
1041
+ prev, x = x, x.parent
1042
+ return {}
1043
+
1044
+ @staticmethod
1045
+ def _accessor(c) -> str | None:
1046
+ """get / set / lazy / delegate: the part of a property node a call sits in (#89)."""
1047
+ a = c.parent
1048
+ while a is not None and a.type not in ("property_declaration", "function_declaration", "class_body"):
1049
+ if a.type == "getter":
1050
+ return "get"
1051
+ if a.type == "setter":
1052
+ return "set"
1053
+ if a.type == "property_delegate":
1054
+ return "lazy" if a.children and re.match(r"by\s+lazy\b", a.text.decode("utf-8", "replace")) else "delegate"
1055
+ a = a.parent
1056
+ return "init" if a is not None and a.type == "property_declaration" else None
1057
+
1058
+ def _prop_ref(self, c, kf: KFile, owner: str, decl: Decl | None):
1059
+ """A read (or write) of a property node: `cart.label`, `summary`, `3.asPrice`, `cart.items = x` (#89)."""
1060
+ p, pre = c.parent, False
1061
+ if c.type == "navigation_expression":
1062
+ kids = [x for x in c.children if x.type != "."]
1063
+ if len(kids) != 2 or kids[1].type != "identifier":
1064
+ return
1065
+ nm, recv = self.t(kids[1]), kids[0]
1066
+ if (recv.type == "unary_expression" and len(recv.children) == 2
1067
+ and recv.children[0].type in ("++", "--")):
1068
+ pre, recv = True, recv.children[1] # `--c.n` parses as `(--c).n`
1069
+ else:
1070
+ if p is None or p.type in ("variable_declaration", "parameter", "class_parameter", "function_value_parameter",
1071
+ "import", "qualified_identifier", "package_header", "user_type", "type_identifier",
1072
+ "enum_entry", "function_declaration", "class_declaration", "object_declaration",
1073
+ "lambda_parameters", "annotation", "constructor_invocation", "label",
1074
+ "callable_reference"):
1075
+ return
1076
+ if p.type == "navigation_expression" and p.children and p.children[0] != c:
1077
+ return
1078
+ if p.type == "value_argument" and c.next_sibling is not None and c.next_sibling.type == "=":
1079
+ return
1080
+ nm, recv = self.t(c), None
1081
+ if nm not in self.props_by_name and nm not in self.field_names:
1082
+ return
1083
+ if p is not None and p.type == "call_expression" and p.children and p.children[0] == c:
1084
+ return # `x.label()`: a call, not a property read
1085
+ mode = "read"
1086
+ if p is not None and p.type == "assignment" and p.children and p.children[0] == c:
1087
+ # `x = v` writes; `x += v` / `x -= v` run the getter and the setter (#105)
1088
+ mode = "write" if len(p.children) > 1 and p.children[1].type == "=" else "read_write"
1089
+ elif pre or p is not None and p.type == "unary_expression" and any(x.type in ("++", "--") for x in p.children):
1090
+ mode = "read_write" # `count++` / `--cart.count`
1091
+ rv = (recv.type, self.t(recv)) if recv is not None else None # text now: self.t reads the current file
1092
+ if nm in self.props_by_name:
1093
+ self.reads.append((owner, nm, rv, c.start_point[0] + 1, kf, decl, mode))
1094
+ if nm in self.field_names:
1095
+ if mode == "read_write": # a stored property read and written in place (#105)
1096
+ self.frefs.append((owner, nm, rv, c.start_point[0] + 1, kf, decl, False))
1097
+ w = mode != "read"
1098
+ if not w and p is not None and p.type == "navigation_expression" and p.children and p.children[0] == c:
1099
+ ids = [x for x in p.children if x.type == "identifier"]
1100
+ gp = p.parent
1101
+ if (len(ids) >= 1 and ids[-1] is not c and self.t(ids[-1]) in KOTLIN_MUTATING and gp is not None
1102
+ and gp.type == "call_expression" and gp.children and gp.children[0] == p):
1103
+ w = "mutating" # `items.add(x)` on a MutableList / Set / Map field
1104
+ elif (len(ids) >= 1 and ids[-1] is not c and self.t(ids[-1]) == "value" and gp is not None
1105
+ and gp.type == "assignment" and gp.children and gp.children[0] == p):
1106
+ w = "value" # `_state.value = x` on a MutableStateFlow / LiveData field
1107
+ self.frefs.append((owner, nm, rv, c.start_point[0] + 1, kf, decl, w))
1108
+
1109
+ def _first_arg(self, args):
1110
+ if args is None:
1111
+ return None
1112
+ for a in args.children:
1113
+ if a.type == "value_argument":
1114
+ for x in a.children:
1115
+ if x.is_named:
1116
+ return x
1117
+ return None
1118
+
1119
+ def _first_string(self, args) -> str | None:
1120
+ x = self._first_arg(args)
1121
+ return self.t(x) if x is not None and x.type == "string_literal" else None
1122
+
1123
+ def _raw_first(self, args) -> str:
1124
+ return self._first_string(args) or '""'
1125
+
1126
+ # ------------------------------------------------------------------ framework facts
1127
+ def _fn_facts(self, d: Decl):
1128
+ anns = {a[0]: a for a in d.annotations}
1129
+ cls = self.classes.get(d.cls) if d.cls else None
1130
+ # Retrofit interface methods
1131
+ for nm, a in anns.items():
1132
+ if nm in RETROFIT and a[1] is not None:
1133
+ self.http.append({"src": d.id, "method": nm, "url": template('"' + a[1] + '"'), "client": "retrofit",
1134
+ "file": d.file, "line": d.line, "relative": True,
1135
+ "iface": (d.cls or "").split(".")[-1]})
1136
+ elif nm == "HTTP":
1137
+ m = re.search(r'method\s*=\s*"(\w+)"', a[2])
1138
+ pm = re.search(r'path\s*=\s*"([^"]*)"', a[2])
1139
+ if m and pm:
1140
+ self.http.append({"src": d.id, "method": m.group(1).upper(), "url": template('"' + pm.group(1) + '"'),
1141
+ "client": "retrofit", "file": d.file, "line": d.line, "relative": True,
1142
+ "iface": (d.cls or "").split(".")[-1]})
1143
+ # Spring MVC / WebFlux annotations
1144
+ if cls is not None and any(nm in SPRING_MAP for nm in anns):
1145
+ canns = {a[0]: a for a in cls.annotations}
1146
+ if not ({"RestController", "Controller"} & set(canns)) and "RequestMapping" not in canns:
1147
+ return
1148
+ prefix = self._mapping_path(canns.get("RequestMapping"))
1149
+ guards = [self._guard(a) for nm2, a in canns.items() if nm2 in SPRING_GUARDS]
1150
+ guards += [self._guard(a) for nm2, a in anns.items() if nm2 in SPRING_GUARDS]
1151
+ for nm, a in anns.items():
1152
+ if nm not in SPRING_MAP:
1153
+ continue
1154
+ verbs = [SPRING_MAP[nm]] if SPRING_MAP[nm] else (re.findall(r"RequestMethod\.(\w+)", a[2]) or ["ANY"])
1155
+ paths = self._mapping_paths(a)
1156
+ for v in verbs:
1157
+ for p in paths:
1158
+ uri = join_path(prefix or "", p) if (prefix or p) else "/"
1159
+ self._route(v, uri, d.id, d.file, d.line, "spring", guards, EXACT)
1160
+
1161
+ def _mapping_paths(self, a) -> list[str]:
1162
+ if a is None:
1163
+ return [""]
1164
+ inner = a[2][a[2].find("(") + 1:a[2].rfind(")")] if "(" in a[2] else ""
1165
+ m = re.search(r"(?:value|path)\s*=\s*(\[[^\]]*\]|arrayOf\([^)]*\)|\"[^\"]*\")", inner)
1166
+ part = m.group(1) if m else (inner if inner and not re.match(r"\s*\w+\s*=", inner) else "")
1167
+ ps = re.findall(r'"((?:[^"\\]|\\.)*)"', part)
1168
+ return ps or [""]
1169
+
1170
+ def _mapping_path(self, a) -> str:
1171
+ return self._mapping_paths(a)[0] if a is not None else ""
1172
+
1173
+ def _guard(self, a) -> str:
1174
+ return f"{a[0]}({a[1]})" if a[1] is not None else a[0]
1175
+
1176
+ def _route(self, method: str, uri: str, handler: str | None, file: str, line: int, fw: str, guards: list,
1177
+ conf: str) -> str:
1178
+ uri = re.sub(r"\{(\w+)(?::[^{}]*)?\}", r"{\1}", uri) # Spring {id:\d+} -> {id}
1179
+ if not uri.startswith("/"):
1180
+ uri = "/" + uri
1181
+ key = f"{method} {uri}"
1182
+ attrs = {"uri": uri, "method": method, "framework": fw}
1183
+ if guards:
1184
+ attrs["middleware"] = list(dict.fromkeys(guards))
1185
+ rid = self.b.add_node("route", key, name=key, file=file, line=line, lang="kotlin", entry_kind="http_route",
1186
+ attrs=attrs)
1187
+ if self.b.nodes[rid].entry_kind is None:
1188
+ self.b.nodes[rid].entry_kind = "http_route"
1189
+ if handler:
1190
+ self.b.add_edge(rid, handler, "ROUTES_TO", file, line, conf)
1191
+ self.b.nodes[rid].attrs.setdefault("handler", self.b.nodes[handler].name if handler in self.b.nodes else handler)
1192
+ self.st["routes"] += 1
1193
+ self.st[f"routes_{fw}"] += 1
1194
+ return rid
1195
+
1196
+ def _ktor_route(self, kf: KFile, method: str, uri: str, c, owner: str, ctx: dict) -> str:
1197
+ line = c.start_point[0] + 1
1198
+ hk = f"{kf.package + '.' if kf.package else ''}<{method} {uri}>@{kf.rel}:{line}"
1199
+ hid = self.b.add_node("function", hk, name=f"{method} {uri} handler", fqn=hk, file=kf.rel, line=line,
1200
+ end_line=c.end_point[0] + 1, module=kf.package or None, lang="kotlin",
1201
+ attrs={"lambda": True, "kotlin_kind": "route handler", **({"test": True} if kf.test else {})})
1202
+ self._route(method, uri, hid, kf.rel, line, "ktor", ctx["guards"], EXACT)
1203
+ norm_uri = _KTOR_PARAM_RE.sub(r"{\1}", uri)
1204
+ self.b.add_edge(owner, f"route:{method} {norm_uri}", "REFERENCES_FN", kf.rel,
1205
+ line, EXACT, how="router registration")
1206
+ return hid
1207
+
1208
+ def _page(self, kf: KFile, route: str, c, typed: bool = False) -> str:
1209
+ line = c.start_point[0] + 1
1210
+ key = f"kotlin:{route}"
1211
+ pid = self.b.add_node("page", key, name=route, fqn=key, file=kf.rel, line=line, end_line=c.end_point[0] + 1,
1212
+ lang="kotlin", entry_kind="ui_page",
1213
+ attrs={"route": route, "via": "compose-navigation" + (" (typed)" if typed else "")})
1214
+ if self.b.nodes[pid].entry_kind is None:
1215
+ self.b.nodes[pid].entry_kind = "ui_page"
1216
+ self.st["compose_pages"] += 1
1217
+ return pid
1218
+
1219
+ def _mark_platform(self, kf: KFile):
1220
+ from ...platforms import Cond, _plat_atom, mark
1221
+ plat = SET_PLATFORM[kf.source_set]
1222
+ lines = kf.src.count(b"\n") + 1
1223
+ mark(self.b, kf.rel, 1, lines, Cond("tree", _plat_atom(plat), f"source set {kf.source_set_dir}"))
1224
+ self.st["platform_source_set_files"] += 1
1225
+
1226
+ # ------------------------------------------------------------------ resolution
1227
+ def _value_ref(self, c, kf: KFile, owner: str, decl: Decl | None):
1228
+ """USES_VALUE to an enum entry or constant named for certain (#84): `Color.RED`, `K.A` (a companion's),
1229
+ `a.b.MAX`, or a bare `RED` / `MAX` that is imported, of the enclosing class or of the file's package, and not
1230
+ a local or parameter of that name."""
1231
+ line = c.start_point[0] + 1
1232
+ if c.type == "navigation_expression":
1233
+ kids = [x for x in c.children if x.type != "."]
1234
+ if len(kids) != 2 or kids[1].type != "identifier":
1235
+ return
1236
+ tgt, nm = self.t(kids[0]), self.t(kids[1])
1237
+ vid = None
1238
+ if re.fullmatch(r"[A-Za-z_][\w.]*", tgt):
1239
+ t = self.classes.get(tgt) or (self._class_of(tgt, kf) if "." not in tgt and tgt[:1].isupper() else None)
1240
+ if t is not None:
1241
+ if t.name == "Companion" and t.cls:
1242
+ t = self.classes.get(t.cls) or t
1243
+ vid = self.values.get(t.fqn, {}).get(nm)
1244
+ elif "." in tgt:
1245
+ vid = self.value_fq.get(f"{tgt}.{nm}")
1246
+ if vid is not None and vid != owner:
1247
+ self.b.add_edge(owner, vid, "USES_VALUE", kf.rel, line, EXACT, how="member")
1248
+ self.st["value_refs"] += 1
1249
+ return
1250
+ p = c.parent
1251
+ if p is None or p.type in ("variable_declaration", "parameter", "class_parameter", "function_value_parameter",
1252
+ "import", "qualified_identifier", "package_header", "user_type", "type_identifier",
1253
+ "enum_entry", "function_declaration", "class_declaration", "object_declaration",
1254
+ "lambda_parameters", "annotation", "constructor_invocation", "label"):
1255
+ return
1256
+ if p.type == "navigation_expression" and p.children and p.children[0] != c:
1257
+ return
1258
+ if p.type == "call_expression" and p.children and p.children[0] == c:
1259
+ return
1260
+ if p.type == "value_argument" and c.next_sibling is not None and c.next_sibling.type == "=":
1261
+ return
1262
+ nm = self.t(c)
1263
+ cls = self.classes.get(decl.cls) if decl is not None and decl.kind != "class" and decl.cls else \
1264
+ (decl if decl is not None and decl.kind == "class" else None)
1265
+ if cls is not None and cls.name == "Companion" and cls.cls:
1266
+ cls = self.classes.get(cls.cls) or cls
1267
+ vid = self.values.get(cls.fqn, {}).get(nm) if cls is not None else None
1268
+ how = "own type"
1269
+ if vid is None:
1270
+ fq = kf.imports.get(nm)
1271
+ vid, how = (self.value_fq.get(fq), "import") if fq else (None, how)
1272
+ if vid is None and not kf.imports.get(nm):
1273
+ vid, how = self.values.get(f"pkg:{kf.package}", {}).get(nm), "package"
1274
+ vn = self.b.nodes.get(vid) if vid else None
1275
+ if vn is not None and _module_root(vn.file) != _module_root(kf.rel):
1276
+ vid = None # the same package in another Gradle module is not visible
1277
+ if vid is None or vid == owner or self._shadowed(decl, nm, kf, line):
1278
+ return
1279
+ if not re.fullmatch(r"[A-Z][A-Z0-9_]*", nm) and self._in_lambda(c):
1280
+ return # `testApplication { client }`: a receiver's member may be meant
1281
+ self.b.add_edge(owner, vid, "USES_VALUE", kf.rel, line, EXACT, how=how)
1282
+ self.st["value_refs"] += 1
1283
+
1284
+ @staticmethod
1285
+ def _in_lambda(c) -> bool:
1286
+ a = c.parent
1287
+ while a is not None and a.type not in ("function_declaration", "class_body", "source_file"):
1288
+ if a.type in ("lambda_literal", "annotated_lambda"):
1289
+ return True
1290
+ a = a.parent
1291
+ return False
1292
+
1293
+ def _shadowed(self, decl: Decl | None, nm: str, kf: KFile, line: int) -> bool:
1294
+ """A parameter, local `val` / `var`, lambda parameter or destructured name `nm` in the function around `line`."""
1295
+ if decl is None or decl.kind == "class":
1296
+ return False
1297
+ if nm in decl.types:
1298
+ return True
1299
+ lines = kf.src.decode("utf-8", "replace").split("\n")[decl.line - 1:decl.end]
1300
+ return bool(re.search(rf"(?:\b(?:val|var)\s+(?:\([^)]*)?|[(,]\s*|\{{\s*(?:[\w\s,]*,\s*)?){re.escape(nm)}\b\s*(?:[:=),]|->|in\b)",
1301
+ "\n".join(lines)))
1302
+
1303
+ def _shadowed_local(self, decl: Decl | None, nm: str, kf: KFile) -> bool:
1304
+ """`_shadowed` for stored-property refs (#88), without its argument-shaped false positives: `{ x = …` (an
1305
+ assignment opening a body) and `f(x)` (an argument) do not declare `x`. A parameter / local / lambda parameter
1306
+ / destructured name / `for (x in …)` does."""
1307
+ if decl is None or decl.kind == "class":
1308
+ return False
1309
+ if nm in decl.types:
1310
+ return True
1311
+ body = "\n".join(kf.src.decode("utf-8", "replace").split("\n")[decl.line - 1:decl.end])
1312
+ n = re.escape(nm)
1313
+ return bool(re.search(rf"\b(?:val|var)\s+(?:\([^)]*?)?\b{n}\b\s*(?:[:=),]|by\b)" # val x = / val (a, x) = / var x by
1314
+ rf"|\bfor\s*\(\s*(?:\([^)]*?)?\b{n}\b[^)]*?\bin\b" # for (x in / for ((a, x) in
1315
+ rf"|[(,]\s*{n}\s*:" # (x: T a parameter
1316
+ rf"|\{{\s*(?:\(?\s*[\w\s,:<>?]*,\s*)?{n}\b\s*(?:,[\w\s,:<>?]*)?\)?\s*->", body)) # { a, x -> / { (a, x) ->
1317
+
1318
+ def _class_of(self, short: str, kf: KFile) -> Decl | None:
1319
+ fq = kf.imports.get(short)
1320
+ if fq and fq in self.classes:
1321
+ return self.classes[fq]
1322
+ if kf.package and f"{kf.package}.{short}" in self.classes:
1323
+ return self.classes[f"{kf.package}.{short}"]
1324
+ cands = self.class_short.get(short) or []
1325
+ return cands[0] if len(cands) == 1 else None
1326
+
1327
+ def _member(self, cls: Decl, name: str, depth: int = 0) -> list[Decl]:
1328
+ hit = self.members.get(cls.fqn, {}).get(name)
1329
+ if hit:
1330
+ return hit
1331
+ if depth > 6:
1332
+ return []
1333
+ for s in cls.supers:
1334
+ sc = self.classes.get(s) or (self.class_short.get(s) or [None])[0]
1335
+ if sc is not None and sc is not cls:
1336
+ r = self._member(sc, name, depth + 1)
1337
+ if r:
1338
+ return r
1339
+ return []
1340
+
1341
+ def _resolve_calls(self):
1342
+ for owner, name, recv, line, kf, decl, acc in self.calls:
1343
+ self._data_access(owner, name, recv, line, kf, decl)
1344
+ self._how = self._recv = None
1345
+ targets = self._targets(name, recv, kf, decl)
1346
+ if not targets:
1347
+ self.st["calls_unresolved"] += 1
1348
+ continue
1349
+ how = {"binding": self._how} if self._how else {}
1350
+ if acc:
1351
+ how["accessor"] = acc
1352
+ # the receiver's class when the member was found on an ancestor: `impact Sub.m` narrows by it (#62)
1353
+ if self._recv is not None and any(t.kind != "class" and t.cls and t.cls != self._recv.fqn for t in targets):
1354
+ how["recv"] = [self._recv.id]
1355
+ if self._how == "candidate":
1356
+ how["candidates"] = len(targets)
1357
+ self.st["call_candidate_edges"] += len(targets)
1358
+ br = self.call_branch.get((owner, line, name), {})
1359
+ for t in targets[:MAX_CANDIDATES if self._how == "candidate" else 3]:
1360
+ if t.kind == "class":
1361
+ self.b.add_edge(owner, t.id, "INSTANTIATES", kf.rel, line, HEURISTIC, **br)
1362
+ ctor = [m for m in self.members.get(t.fqn, {}).get("init", [])]
1363
+ self.b.add_edge(owner, t.id, "USES_TYPE", kf.rel, line, HEURISTIC, how="constructor call")
1364
+ for m in ctor:
1365
+ self.b.add_edge(owner, m.id, "CALLS", kf.rel, line, HEURISTIC)
1366
+ else:
1367
+ composable = br and any(a[0] == "Composable" for a in t.annotations)
1368
+ self.b.add_edge(owner, t.id, "CALLS", kf.rel, line, HEURISTIC, **how, **(br if composable else {}))
1369
+ self.st["calls_resolved"] += 1
1370
+ self._resolve_reads()
1371
+ self._resolve_fields()
1372
+ self._resolve_copies()
1373
+
1374
+ def _copy_call(self, c, kf: KFile, owner: str, decl: Decl | None):
1375
+ """`state.copy(loading = true)` on a data class: a write of each named field, `via: copy` (#88)."""
1376
+ if not self.field_names or not c.children or c.children[0].type != "navigation_expression":
1377
+ return
1378
+ nav = c.children[0]
1379
+ kids = [x for x in nav.children if x.type != "."]
1380
+ if len(kids) != 2 or kids[1].type != "identifier" or self.t(kids[1]) != "copy":
1381
+ return
1382
+ args = next((x for x in c.children if x.type == "value_arguments"), None)
1383
+ named = []
1384
+ for a in (args.children if args is not None else []):
1385
+ if a.type == "value_argument":
1386
+ ids = [x for x in a.children if x.type == "identifier"]
1387
+ eq = next((x for x in a.children if x.type == "="), None)
1388
+ if ids and eq is not None and ids[0].start_byte < eq.start_byte:
1389
+ named.append((self.t(ids[0]), a.start_point[0] + 1))
1390
+ if named:
1391
+ self.copies.append((owner, (kids[0].type, self.t(kids[0])), named, kf, decl))
1392
+
1393
+ def _resolve_copies(self):
1394
+ """A receiver of known type binds `resolved`. Otherwise (`it.copy(...)` in `update { }`) the one data class
1395
+ whose fields include every named argument binds `heuristic`, `binding: name`; none or several bind nothing."""
1396
+ for owner, (rtype, rt), named, kf, decl in self.copies:
1397
+ cls = self.classes.get(decl.cls) if decl is not None and decl.cls else None
1398
+ rname = re.sub(r"[?!]", "", rt).split(".")[-1].strip()
1399
+ tyname = decl.types.get(rname) if decl is not None else None
1400
+ if tyname is None and cls is not None:
1401
+ tyname = self._field_type(cls, rname)
1402
+ if tyname is None and decl is not None and decl.kind != "class" and rtype == "identifier":
1403
+ tyname = self._local_type(decl, rname, kf)
1404
+ tc = self._class_of(re.sub(r"[<(].*", "", tyname, flags=re.S).rstrip("?! ").split(".")[-1], kf) if tyname else None
1405
+ conf, extra = RESOLVED, {}
1406
+ if tc is None:
1407
+ want = {n for n, _ in named}
1408
+ cands = [k for k, f in self.fields.items() if want <= set(f)
1409
+ and "data" in getattr(self.classes.get(k), "modifiers", set())]
1410
+ if len(cands) != 1:
1411
+ self.st["copy_unresolved"] += 1
1412
+ continue
1413
+ tc, conf, extra = self.classes.get(cands[0]), HEURISTIC, {"binding": "name"}
1414
+ for n, line in named:
1415
+ fid = self._field_member(tc, n)
1416
+ if fid and fid != owner:
1417
+ self.b.add_edge(owner, fid, "WRITES_PROP", kf.rel, line, conf, receiver=rt[:40], via="copy", **extra)
1418
+ self.st["stored_property_writes"] += 1
1419
+
1420
+ def _field_member(self, cls: Decl | None, name: str, depth: int = 0) -> str | None:
1421
+ if cls is None or depth > 6:
1422
+ return None
1423
+ if name in self.fields.get(cls.fqn, {}):
1424
+ return self.fields[cls.fqn][name]
1425
+ for s_ in cls.supers:
1426
+ sc = self.classes.get(s_) or (self.class_short.get(s_) or [None])[0]
1427
+ if sc is not None and sc is not cls:
1428
+ r = self._field_member(sc, name, depth + 1)
1429
+ if r:
1430
+ return r
1431
+ return None
1432
+
1433
+ def _resolve_fields(self):
1434
+ """READS_PROP / WRITES_PROP to stored-property field nodes (#88): `x` / `this.x` inside the class (or a
1435
+ subclass), `v.x` where the type of `v` is known (a parameter, a property, a local `val v = T(...)`). An
1436
+ unknown receiver binds nothing: stored names are far too common."""
1437
+ for owner, name, recv, line, kf, decl, write in self.frefs:
1438
+ cls = self.classes.get(decl.cls) if decl is not None and decl.cls else (
1439
+ decl if decl is not None and decl.kind == "class" else None)
1440
+ fid = None
1441
+ rt = recv[1] if recv is not None else None
1442
+ if recv is None or rt == "this":
1443
+ if recv is None and self._shadowed_local(decl, name, kf):
1444
+ continue
1445
+ fid = self._field_member(cls, name)
1446
+ elif recv[0] in ("identifier", "this_expression") or re.fullmatch(r"[\w.]+", rt or ""):
1447
+ rname = rt.split(".")[-1].strip()
1448
+ tyname = decl.types.get(rname) if decl is not None else None
1449
+ if tyname is None and cls is not None:
1450
+ tyname = self._field_type(cls, rname)
1451
+ if tyname is None and decl is not None and decl.kind != "class":
1452
+ tyname = self._local_type(decl, rname, kf)
1453
+ if tyname:
1454
+ tc = self._class_of(re.sub(r"[<(].*", "", tyname, flags=re.S).rstrip("?! ").split(".")[-1], kf)
1455
+ fid = self._field_member(tc, name)
1456
+ if fid is None or fid == owner:
1457
+ self.st["stored_property_refs_unresolved"] += 1
1458
+ continue
1459
+ self.b.add_edge(owner, fid, "WRITES_PROP" if write else "READS_PROP", kf.rel, line, RESOLVED,
1460
+ receiver="this" if recv is None or rt == "this" else rt[:40],
1461
+ **({"via": write} if isinstance(write, str) else {}))
1462
+ self.st["stored_property_writes" if write else "stored_property_reads"] += 1
1463
+
1464
+ def _local_type(self, decl: Decl, name: str, kf: KFile) -> str | None:
1465
+ """`val d = Cart(...)` inside the function body: the constructed type of a local."""
1466
+ src = "\n".join(kf.src.decode("utf-8", "replace").split("\n")[decl.line - 1:decl.end])
1467
+ m = re.search(r"\b(?:val|var)\s+" + re.escape(name) + r"\s*(?::\s*([A-Z][\w.]*))?\s*=\s*([A-Z][\w.]*)\s*\(", src)
1468
+ return (m.group(1) or m.group(2)) if m else None
1469
+
1470
+ def _prop_member(self, cls: Decl, name: str, depth: int = 0) -> list[Decl]:
1471
+ hit = self.prop_members.get(cls.fqn, {}).get(name)
1472
+ if hit:
1473
+ return hit
1474
+ if depth > 6:
1475
+ return []
1476
+ for s_ in cls.supers:
1477
+ sc = self.classes.get(s_) or (self.class_short.get(s_) or [None])[0]
1478
+ if sc is not None and sc is not cls:
1479
+ r = self._prop_member(sc, name, depth + 1)
1480
+ if r:
1481
+ return r
1482
+ return []
1483
+
1484
+ def _ext_props(self, name: str, ty: str) -> list[Decl]:
1485
+ return [d for d in self.props_by_name.get(name, []) if d.receiver and d.receiver.split(".")[-1] == ty]
1486
+
1487
+ def _resolve_reads(self):
1488
+ """Property reads / writes -> CALLS with `property: read | write`, on the receiver rules of calls (#83): a known
1489
+ receiver type binds exactly or not at all; an unknown receiver binds only a name no stored property shares."""
1490
+ LIT = {"integer_literal": "Int", "long_literal": "Long", "string_literal": "String", "real_literal": "Double",
1491
+ "boolean_literal": "Boolean", "character_literal": "Char"}
1492
+ for owner, name, recv, line, kf, decl, mode in self.reads:
1493
+ cls = self.classes.get(decl.cls) if decl is not None and decl.cls else (
1494
+ decl if decl is not None and decl.kind == "class" else None)
1495
+ how, r = None, []
1496
+ rt = recv[1] if recv is not None else None
1497
+ if recv is None or rt == "this":
1498
+ if recv is None and self._shadowed_local(decl, name, kf):
1499
+ continue # not `_shadowed`: `{ x = 1` and `f(x)` do not declare `x` (#105)
1500
+ if cls is not None:
1501
+ r = self._prop_member(cls, name)
1502
+ if not r and decl is not None and decl.receiver:
1503
+ rc = self._class_of(decl.receiver.split(".")[-1], kf)
1504
+ r = self._prop_member(rc, name) if rc is not None else []
1505
+ r = r or self._ext_props(name, decl.receiver.split(".")[-1])
1506
+ if not r and recv is None:
1507
+ if cls is not None and (name in cls.types or self._field_type(cls, name)):
1508
+ continue # a stored property of the class shadows a top-level one
1509
+ fq = kf.imports.get(name)
1510
+ r = [d for d in self.props_by_name[name] if d.kind == "function" and not d.receiver
1511
+ and (d.fqn == fq if fq else d.fqn == f"{kf.package}.{name}")]
1512
+ else:
1513
+ rname = re.sub(r"[?!]", "", rt).split(".")[-1].strip()
1514
+ tyname = LIT.get(recv[0])
1515
+ if recv[0] == "number_literal":
1516
+ tyname = "Long" if rt.rstrip().endswith(("L", "l")) else ("Double" if "." in rt else "Int")
1517
+ if tyname is None and decl is not None:
1518
+ tyname = decl.types.get(rname)
1519
+ if tyname is None and cls is not None:
1520
+ tyname = self._field_type(cls, rname)
1521
+ if tyname is None and rname[:1].isupper():
1522
+ tyname = re.sub(r"\s*[({].*$", "", rname, flags=re.S)
1523
+ if tyname is None and rname in self.props_by_name: # `cart.label.length` style chains
1524
+ pt = {self.props[d.id].get("type") for d in self.props_by_name[rname]} - {None}
1525
+ tyname = pt.pop() if len(pt) == 1 else None
1526
+ if tyname:
1527
+ short = re.sub(r"[<(].*", "", tyname, flags=re.S).rstrip("?! ").split(".")[-1]
1528
+ tc = self._class_of(short, kf)
1529
+ r = (self._prop_member(tc, name) if tc is not None else []) or self._ext_props(name, short)
1530
+ if not r and tc is not None:
1531
+ comp = self.classes.get(f"{tc.fqn}.Companion")
1532
+ r = self._prop_member(comp, name) if comp else []
1533
+ else:
1534
+ # extension / top-level properties never bind by name: `x.size` is far more often the library's
1535
+ cands = list({d.id: d for d in self.props_by_name[name] if d.cls}.values())
1536
+ if len(cands) == 1 and name not in self.stored_names and len(name) > 3:
1537
+ r, how = cands, "name"
1538
+ if mode == "write":
1539
+ r = [d for d in r if {"set", "delegate"} & set(self.props[d.id]["accessors"])]
1540
+ for t in r[:3]:
1541
+ if t.id == owner:
1542
+ continue
1543
+ m = mode
1544
+ if m == "read_write" and not {"set", "delegate"} & set(self.props[t.id]["accessors"]):
1545
+ m = "read" # only the getter is a node's code: the setter is the default
1546
+ self.b.add_edge(owner, t.id, "CALLS", kf.rel, line, HEURISTIC, property=m,
1547
+ **({"binding": how} if how else {}))
1548
+ self.st[{"read": "property_reads", "write": "property_writes"}.get(m, "property_read_writes")] += 1
1549
+
1550
+ def _targets(self, name: str, recv: str | None, kf: KFile, decl: Decl | None) -> list[Decl]:
1551
+ cls = self.classes.get(decl.cls) if decl is not None and decl.cls else None
1552
+ if recv is None or recv == "this":
1553
+ if cls is not None:
1554
+ r = [d for d in self._member(cls, name) if d.kind != "class"]
1555
+ if r:
1556
+ self._recv = cls
1557
+ return self._same_set(r, kf)
1558
+ if decl is not None and decl.receiver: # extension function: members of its receiver
1559
+ rc = self._class_of(decl.receiver.split(".")[-1], kf)
1560
+ if rc is not None:
1561
+ r = self._member(rc, name)
1562
+ if r:
1563
+ return r
1564
+ if name[:1].isupper():
1565
+ c = self._class_of(name, kf)
1566
+ if c is not None:
1567
+ return [c]
1568
+ fq = kf.imports.get(name)
1569
+ if fq:
1570
+ r = [d for d in self.by_name.get(name, []) if d.fqn == fq and d.kind == "function"]
1571
+ if r:
1572
+ return r
1573
+ same = [d for d in self.by_name.get(name, []) if d.kind == "function" and d.fqn == f"{kf.package}.{name}"
1574
+ and "actual" not in d.modifiers]
1575
+ if same:
1576
+ return same
1577
+ cands = [d for d in self.by_name.get(name, []) if d.kind == "function" and "actual" not in d.modifiers]
1578
+ return cands if len(cands) == 1 else []
1579
+ rname = re.sub(r"[?!]", "", recv).split(".")[-1].strip()
1580
+ tyname = None
1581
+ if decl is not None:
1582
+ tyname = decl.types.get(rname)
1583
+ if tyname is None and cls is not None:
1584
+ tyname = self._field_type(cls, rname)
1585
+ if tyname is None and rname[:1].isupper():
1586
+ tyname = re.sub(r"\s*[({].*$", "", rname, flags=re.S) # `WishController().add(x)`: a WishController
1587
+ if tyname:
1588
+ tc = self._class_of(tyname, kf)
1589
+ if tc is not None:
1590
+ r = self._member(tc, name)
1591
+ if not r:
1592
+ comp = self.classes.get(f"{tc.fqn}.Companion")
1593
+ r = self._member(comp, name) if comp else []
1594
+ if r:
1595
+ self._recv = tc
1596
+ return self._same_set(r, kf)
1597
+ return []
1598
+ short = re.sub(r"[<(].*", "", tyname, flags=re.S).rstrip("?! ").split(".")[-1]
1599
+ if len(short) > 1 and short[:1].isupper() and not self.class_short.get(short):
1600
+ # a library type (`DataStoreFactory.create(...)`, `Headers.build { }`, `client: HttpClient`): a known
1601
+ # receiver type binds exactly or not at all (#83), never by name to a project method
1602
+ self.st["calls_library_receiver"] += 1
1603
+ return []
1604
+ # unknown receiver: the name decides. One method of that name is a `name` binding; several (on up to
1605
+ # MAX_CANDIDATES classes) are `candidate` edges to each (#83 item 6): kept, never a confident edge, left out
1606
+ # of divergence and flagged by `cg tests` / impact
1607
+ cands = list({d.id: d for d in self.by_name.get(name, []) if d.kind == "method"
1608
+ and "actual" not in d.modifiers}.values())
1609
+ if not cands or len(name) <= 3:
1610
+ return []
1611
+ if len(cands) > MAX_CANDIDATES:
1612
+ self.st["calls_too_ambiguous"] += 1
1613
+ return []
1614
+ self._how = "name" if len(cands) == 1 else "candidate"
1615
+ return cands
1616
+
1617
+ @staticmethod
1618
+ def _same_set(cands: list[Decl], kf: KFile) -> list[Decl]:
1619
+ """Members of an `expect` class and its `actual`s share one name (#86): code in a platform source set calls the
1620
+ member of its own `actual`, common code the `expect` one; an `actual` from another platform's set never."""
1621
+ if len(cands) < 2 or not any("actual" in d.modifiers for d in cands):
1622
+ return cands
1623
+ same = [d for d in cands if d.file == kf.rel]
1624
+ if same:
1625
+ return same
1626
+ own = [d for d in cands if "actual" in d.modifiers and kf.source_set and d.id.endswith(f"@{kf.source_set}")]
1627
+ return own or [d for d in cands if "actual" not in d.modifiers] or cands
1628
+
1629
+ def _field_type(self, cls: Decl, name: str, depth=0):
1630
+ if name in cls.types:
1631
+ return cls.types[name]
1632
+ if cls.cls and depth < 4 and cls.cls in self.classes:
1633
+ return self._field_type(self.classes[cls.cls], name, depth + 1)
1634
+ return None
1635
+
1636
+ def _hierarchy(self):
1637
+ for d in list(self.decls.values()):
1638
+ if d.kind != "class":
1639
+ continue
1640
+ for s in d.supers:
1641
+ sc = self.classes.get(s) or (self.class_short.get(s) or [None])[0]
1642
+ if sc is None:
1643
+ if s in WORKER_BASES:
1644
+ self._entry_class(d, "queue_job", f"{s} subclass")
1645
+ continue
1646
+ kind = "IMPLEMENTS" if self.b.nodes[sc.id].attrs.get("kotlin_kind") == "interface" else "EXTENDS"
1647
+ self.b.add_edge(d.id, sc.id, kind, d.file, d.line, HEURISTIC)
1648
+ for nm, ms in self.members.get(d.fqn, {}).items():
1649
+ for base in self.members.get(sc.fqn, {}).get(nm, []):
1650
+ for m in ms:
1651
+ ek = "IMPLEMENTED_BY" if kind == "IMPLEMENTS" or "abstract" in base.modifiers else "OVERRIDDEN_BY"
1652
+ self.b.add_edge(base.id, m.id, ek, m.file, m.line, HEURISTIC)
1653
+ # expect -> actual
1654
+ for d in list(self.decls.values()):
1655
+ if "actual" in d.modifiers:
1656
+ base = self.decls.get(d.id.split("@")[0])
1657
+ if base is not None and "expect" in base.modifiers:
1658
+ self.b.add_edge(base.id, d.id, "IMPLEMENTED_BY", d.file, d.line, EXACT, how="expect/actual")
1659
+ self.st["expect_actual"] += 1
1660
+
1661
+ def _entry_class(self, d: Decl, kind: str, why: str):
1662
+ n = self.b.nodes[d.id]
1663
+ n.entry_kind = n.entry_kind or kind
1664
+ n.attrs["entry_reason"] = why
1665
+ for nm, ms in self.members.get(d.fqn, {}).items():
1666
+ if LIFECYCLE.match(nm):
1667
+ for m in ms:
1668
+ self.b.add_edge(d.id, m.id, "REFERENCES_FN", m.file, m.line, EXACT, how="framework lifecycle")
1669
+ self.st[f"entries_{kind}"] += 1
1670
+
1671
+ # ------------------------------------------------------------------ typed navigation / resources
1672
+ def _typed_block(self, name: str, typ: str, c, lam, kf: KFile, owner: str, decl: Decl | None, ctx: dict) -> bool:
1673
+ """`composable<Route>(...) { }` / Navigation 3 `entry<Key> { }` as typed pages; Ktor type-safe resources
1674
+ `get<Articles> { }` inside routing as routes (path from the @Resource class)."""
1675
+ short = typ.split(".")[-1].split("<")[0].strip()
1676
+ if not re.fullmatch(r"[A-Z]\w*", short or ""):
1677
+ return False
1678
+ if name in ("composable", "entry", "dialog"):
1679
+ pid = self._page(kf, short, c, typed=True)
1680
+ self._refs(lam, kf, pid, decl, ctx)
1681
+ return True
1682
+ if name in VERBS and ctx.get("routing"):
1683
+ path = self._resource_path(short, kf)
1684
+ if path is None:
1685
+ return False
1686
+ hid = self._ktor_route(kf, name.upper(), join_path(ctx["prefix"], path), c, owner, ctx)
1687
+ self.st["routes_ktor_resources"] += 1
1688
+ self._refs(lam, kf, hid, decl, {**ctx, "routing": False})
1689
+ return True
1690
+ return False
1691
+
1692
+ def _resource_path(self, short: str, kf: KFile, depth: int = 0) -> str | None:
1693
+ """Path of a Ktor @Resource class; a `parent` property typed with another resource prefixes it."""
1694
+ cls = self._class_of(short, kf)
1695
+ if cls is None or depth > 6:
1696
+ return None
1697
+ ann = next((a for a in cls.annotations if a[0] == "Resource"), None)
1698
+ if ann is None or ann[1] is None:
1699
+ return None
1700
+ path = template('"' + ann[1] + '"')
1701
+ ptype = cls.types.get("parent")
1702
+ if ptype:
1703
+ pp = self._resource_path(ptype.split(".")[-1].rstrip("?"), kf, depth + 1)
1704
+ if pp is not None:
1705
+ return join_path(pp, path)
1706
+ if cls.cls and depth == 0 and not path.startswith("/"): # nested class of a resource without `parent`
1707
+ outer = self.classes.get(cls.cls)
1708
+ pp = self._resource_path(outer.name, kf, depth + 1) if outer else None
1709
+ if pp is not None:
1710
+ return join_path(pp, path)
1711
+ return path
1712
+
1713
+ # ------------------------------------------------------------------ Gradle buildConfigField
1714
+ def _build_config(self, root: Path, files: list[str]) -> dict[str, list[str]]:
1715
+ """String buildConfigField values of the Gradle build files (`buildConfigField("String", "BASE_URL",
1716
+ "\\"https://...\\"")`): Retrofit `baseUrl(BuildConfig.BASE_URL)` resolves through them."""
1717
+ out: dict[str, list[str]] = defaultdict(list)
1718
+ cands = [f for f in files if f.endswith(("build.gradle.kts",))]
1719
+ for d in {Path(f).parent for f in files if f.endswith(".kt")}:
1720
+ for up in [d, *d.parents][:8]:
1721
+ g = up / "build.gradle"
1722
+ if (root / g).is_file():
1723
+ cands.append(str(g))
1724
+ rx = re.compile(r'buildConfigField\s*\(?\s*["\']String["\']\s*,\s*["\'](\w+)["\']\s*,\s*'
1725
+ r'(?:"\\"([^"\\]*)\\""|\'"([^"\']*)"\')')
1726
+ for f in sorted(set(cands)):
1727
+ try:
1728
+ txt = (root / f).read_text(errors="replace")
1729
+ except OSError:
1730
+ continue
1731
+ for m in rx.finditer(txt):
1732
+ v = m.group(2) if m.group(2) is not None else m.group(3)
1733
+ if v not in out[m.group(1)]:
1734
+ out[m.group(1)].append(v)
1735
+ return dict(out)
1736
+
1737
+ # ------------------------------------------------------------------ Spring Data / Exposed tables
1738
+ def _data_models(self, kfiles: list[KFile]):
1739
+ self.repos: dict[str, str] = {} # repository interface short name -> table
1740
+ self.exposed: dict[str, str] = {} # Exposed table object short name -> table
1741
+ rx_repo = re.compile(r"\binterface\s+(\w+)\s*(?:<[^>{]*>)?\s*:[^{]*?\b" + REPO_SUPERS + r"\s*<\s*([\w.]+)")
1742
+ rx_tab = re.compile(r"\bobject\s+(\w+)\s*:\s*(?:[\w.]+\.)?" + EXPOSED_TABLES + r"(?:<[^>]*>)?\s*\(\s*(?:name\s*=\s*)?"
1743
+ r"(\"[^\"]*\")?")
1744
+ for kf in kfiles:
1745
+ txt = kf.src.decode("utf-8", "replace")
1746
+ if "Repository" in txt:
1747
+ for m in rx_repo.finditer(txt):
1748
+ self.repos[m.group(1)] = self._entity_table(m.group(2).split(".")[-1], kf)
1749
+ if "Table" in txt and "exposed" in txt:
1750
+ for m in rx_tab.finditer(txt):
1751
+ name = m.group(2).strip('"') if m.group(2) else re.sub(r"Table$", "", m.group(1))
1752
+ self.exposed[m.group(1)] = name or m.group(1)
1753
+
1754
+ def _entity_table(self, entity: str, kf: KFile) -> str:
1755
+ """@Table(name = "owners") on the entity, else Spring Boot's default naming (CamelCase -> snake_case)."""
1756
+ cls = self._class_of(entity, kf)
1757
+ if cls is not None:
1758
+ ann = next((a for a in cls.annotations if a[0] in ("Table", "Document")), None)
1759
+ if ann is not None and ann[1]:
1760
+ return ann[1]
1761
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", entity).lower()
1762
+
1763
+ def _table(self, name: str, via: str) -> str:
1764
+ tid = self.b.add_node("table", name, lang="sql", attrs={"inferred": True, "via": via})
1765
+ return tid
1766
+
1767
+ def _data_access(self, owner: str, name: str, recv: str | None, line: int, kf: KFile, decl: Decl | None):
1768
+ if not (self.repos or self.exposed):
1769
+ return
1770
+ kind = "WRITES_TABLE" if DATA_WRITE.match(name) else "READS_TABLE" if DATA_READ.match(name) else None
1771
+ if kind is None:
1772
+ return
1773
+ rname = re.sub(r"[?!]", "", recv).split(".")[-1].strip() if recv else None
1774
+ if rname in self.exposed: # Users.selectAll() / Users.insert { }
1775
+ self.b.add_edge(owner, self._table(self.exposed[rname], "exposed"), kind, kf.rel, line, RESOLVED,
1776
+ via=f"exposed {name}")
1777
+ self.st["table_access_exposed"] += 1
1778
+ return
1779
+ repo = None
1780
+ if recv is None or recv == "this":
1781
+ if decl is not None and decl.cls and decl.cls.split(".")[-1] in self.repos: # default method of the repo
1782
+ repo = decl.cls.split(".")[-1]
1783
+ else:
1784
+ cls = self.classes.get(decl.cls) if decl is not None and decl.cls else None
1785
+ ty = decl.types.get(rname) if decl is not None else None
1786
+ if ty is None and cls is not None:
1787
+ ty = self._field_type(cls, rname)
1788
+ ty = (ty or "").split(".")[-1].split("<")[0].rstrip("?")
1789
+ if ty in self.repos:
1790
+ repo = ty
1791
+ if repo is not None: # owners.findById(id) on an OwnerRepository
1792
+ self.b.add_edge(owner, self._table(self.repos[repo], "spring-data"), kind, kf.rel, line, RESOLVED,
1793
+ via=f"{repo}.{name}")
1794
+ self.st["table_access_spring_data"] += 1
1795
+
1796
+ # ------------------------------------------------------------------ Spring Security
1797
+ def _security_rules(self, kfiles: list[KFile]):
1798
+ """URL rules of SecurityFilterChain beans (`requestMatchers("/admin/**").hasRole("ADMIN")`, Kotlin DSL
1799
+ `authorize("/admin/**", hasRole("ADMIN"))`), first match wins, as guards on the Spring routes they cover."""
1800
+ rules = []
1801
+ rx_chain = re.compile(r"\b(?:requestMatchers|antMatchers|mvcMatchers|pathMatchers)\s*\(([^()]*)\)\s*\.\s*"
1802
+ + SEC_AUTH + r"\s*\(([^()]*)\)")
1803
+ rx_dsl = re.compile(r"\bauthorize\s*\(\s*(?:HttpMethod\.(\w+)\s*,\s*)?(\"[^\"]*\"|anyRequest)\s*,\s*"
1804
+ + SEC_AUTH + r"\b(?:\s*\(([^()]*)\))?")
1805
+ rx_any = re.compile(r"\banyRequest\s*\(\s*\)\s*\.\s*" + SEC_AUTH + r"\s*\(([^()]*)\)")
1806
+ for kf in kfiles:
1807
+ txt = kf.src.decode("utf-8", "replace")
1808
+ if "SecurityFilterChain" not in txt and "SecurityWebFilterChain" not in txt:
1809
+ continue
1810
+ found = []
1811
+ for m in rx_chain.finditer(txt):
1812
+ meth = re.search(r"HttpMethod\.(\w+)", m.group(1))
1813
+ pats = re.findall(r'"([^"]*)"', m.group(1))
1814
+ found.append((m.start(), meth.group(1).upper() if meth else None, pats, m.group(2), m.group(3)))
1815
+ for m in rx_dsl.finditer(txt):
1816
+ pats = ["/**"] if m.group(2) == "anyRequest" else [m.group(2).strip('"')]
1817
+ found.append((m.start(), (m.group(1) or "").upper() or None, pats, m.group(3), m.group(4) or ""))
1818
+ for m in rx_any.finditer(txt):
1819
+ found.append((m.start(), None, ["/**"], m.group(1), m.group(2)))
1820
+ for _, meth, pats, auth, arg in sorted(found, key=lambda x: x[0]):
1821
+ roles = ",".join(re.findall(r'"([^"]*)"', arg))
1822
+ g = None if auth == "permitAll" else (f"{auth}({roles})" if roles else auth)
1823
+ rules.append((meth, [self._ant(p) for p in pats if p], g, kf.rel))
1824
+ if not rules:
1825
+ return
1826
+ self.st["security_rules"] = len(rules)
1827
+ for nid, n in self.b.nodes.items():
1828
+ if n.kind != "route" or n.lang != "kotlin" or n.attrs.get("framework") != "spring":
1829
+ continue
1830
+ uri, meth = n.attrs.get("uri", ""), n.attrs.get("method")
1831
+ probe = re.sub(r"\{\w+\}", "x", uri)
1832
+ for rm, rxs, g, rel in rules:
1833
+ if rm and rm != meth:
1834
+ continue
1835
+ if any(r.fullmatch(probe) for r in rxs):
1836
+ if g:
1837
+ mw = n.attrs.setdefault("middleware", [])
1838
+ if g not in mw:
1839
+ mw.append(g)
1840
+ n.attrs.setdefault("security", f"SecurityFilterChain ({rel})")
1841
+ self.st["routes_guarded_by_security_chain"] += 1
1842
+ break
1843
+
1844
+ @staticmethod
1845
+ def _ant(p: str):
1846
+ """Spring path pattern -> regex: `**` any depth, `*` one segment part, `{x}` one segment."""
1847
+ out, i = "", 0
1848
+ while i < len(p):
1849
+ if p.startswith("/**", i):
1850
+ out += r"(?:/.*)?"
1851
+ i += 3
1852
+ elif p.startswith("**", i):
1853
+ out += r".*"
1854
+ i += 2
1855
+ elif p[i] == "*":
1856
+ out += r"[^/]*"
1857
+ i += 1
1858
+ elif p[i] == "{":
1859
+ j = p.find("}", i)
1860
+ out += r"[^/]+"
1861
+ i = j + 1 if j > 0 else len(p)
1862
+ else:
1863
+ out += re.escape(p[i])
1864
+ i += 1
1865
+ return re.compile(out)
1866
+
1867
+ def _emit_http(self):
1868
+ bases = sorted(self.base_urls)
1869
+ base = bases[0] if len(bases) == 1 else None
1870
+ for r in self.http:
1871
+ url = r["url"]
1872
+ own = self.api_base.get(r.get("iface") or "")
1873
+ base = sorted(own)[0] if own and len(own) == 1 else (bases[0] if len(bases) == 1 else None)
1874
+ if r.get("relative") and base and not re.match(r"^[a-zA-Z][\w+.-]*://", url):
1875
+ bo, bp = split_url(base)
1876
+ origin, path = bo, (join_path(bp, url) if not url.startswith("/") else url)
1877
+ else:
1878
+ origin, path = split_url(url)
1879
+ okind = "api" if origin is None or (base and origin == split_url(base)[0]) else (
1880
+ "unknown" if origin.startswith("{") else "other")
1881
+ key = f"{r['method']} {path}" if okind in ("api", "unknown") else f"{r['method']} {origin}{path}"
1882
+ nid = self.b.add_node("http", key, key, fqn=key, lang="kotlin",
1883
+ attrs={"method": r["method"], "path": path, "client": r["client"], "origin": origin,
1884
+ "origin_kind": okind})
1885
+ self.b.add_edge(r["src"], nid, "HTTP_CALLS", r["file"], r["line"], HEURISTIC if okind != "api" else EXACT,
1886
+ client=r["client"], url=url, origin=origin)
1887
+ self.st[f"http_{r['client']}"] += 1
1888
+
1889
+ def _link_navs(self):
1890
+ from ...link import match_path
1891
+ pages = [(nid, n.attrs.get("route")) for nid, n in self.b.nodes.items() if n.kind == "page" and n.lang == "kotlin"]
1892
+ for owner, route, typ, file, line in self.navs:
1893
+ hits = []
1894
+ for nid, pr in pages:
1895
+ if typ is not None:
1896
+ if pr == typ:
1897
+ hits.append(nid)
1898
+ elif route is not None and pr is not None:
1899
+ ok, _ = match_path("/" + route.split("?")[0].lstrip("/"), "/" + pr.split("?")[0].lstrip("/"))
1900
+ if ok:
1901
+ hits.append(nid)
1902
+ for h in hits[:2]:
1903
+ self.b.add_edge(owner, h, "NAVIGATES_TO", file, line, EXACT if len(hits) == 1 else HEURISTIC)
1904
+ self.st["navigations"] += 1
1905
+
1906
+ # ------------------------------------------------------------------ AndroidManifest.xml
1907
+ def _manifests(self, project: Project):
1908
+ ns = "{http://schemas.android.com/apk/res/android}"
1909
+ rules = path_rules(project, "kotlin")
1910
+ for dp, dn, fn in os.walk(project.root):
1911
+ rd = os.path.relpath(dp, project.root).replace(os.sep, "/")
1912
+ rd = "" if rd == "." else rd
1913
+ dn[:] = rules.prune(rd, dn, dot=True)
1914
+ if "AndroidManifest.xml" not in fn:
1915
+ continue
1916
+ rel = f"{rd}/AndroidManifest.xml" if rd else "AndroidManifest.xml"
1917
+ if TEST_PATH.search(rel):
1918
+ continue
1919
+ try:
1920
+ root = ET.parse(os.path.join(dp, "AndroidManifest.xml")).getroot()
1921
+ except (ET.ParseError, OSError):
1922
+ continue
1923
+ pkg = root.get("package") or self._gradle_namespace(Path(dp))
1924
+ app = root.find("application")
1925
+ if app is None:
1926
+ continue
1927
+ for kind_tag, entry in (("activity", "ui_page"), ("activity-alias", "ui_page"), ("service", "listener"),
1928
+ ("receiver", "listener"), ("provider", "listener")):
1929
+ for el in app.findall(kind_tag):
1930
+ nm = el.get(ns + "name") or el.get(ns + "targetActivity")
1931
+ if not nm:
1932
+ continue
1933
+ fq = (pkg + nm) if nm.startswith(".") and pkg else (nm if "." in nm else f"{pkg}.{nm}" if pkg else nm)
1934
+ d = self.classes.get(fq) or ((self.class_short.get(fq.split(".")[-1]) or [None])
1935
+ if len(self.class_short.get(fq.split(".")[-1]) or []) == 1 else [None])[0]
1936
+ if d is None:
1937
+ self.st["manifest_unresolved"] += 1
1938
+ continue
1939
+ self._entry_class(d, entry, f"AndroidManifest <{kind_tag}>")
1940
+ self.b.nodes[d.id].attrs["android_component"] = kind_tag
1941
+ for data in el.iter("data"):
1942
+ scheme, host = data.get(ns + "scheme"), data.get(ns + "host")
1943
+ path = data.get(ns + "path") or data.get(ns + "pathPrefix") or data.get(ns + "pathPattern") or ""
1944
+ if not scheme and not host:
1945
+ continue
1946
+ route = f"{scheme or '*'}://{host or '*'}{path}"
1947
+ pid = self.b.add_node("page", f"kotlin:deeplink:{route}", name=route, file=rel, lang="kotlin",
1948
+ entry_kind="ui_page", attrs={"route": route, "via": "android deep link",
1949
+ "activity": d.fqn})
1950
+ self.b.add_edge(pid, d.id, "ROUTES_TO", rel, None, EXACT)
1951
+ self.st["deep_links"] += 1
1952
+
1953
+ def _gradle_namespace(self, d: Path) -> str | None:
1954
+ for up in (d, d.parent, d.parent.parent, d.parent.parent.parent):
1955
+ for g in ("build.gradle.kts", "build.gradle"):
1956
+ f = up / g
1957
+ if f.is_file():
1958
+ m = re.search(r"namespace\s*=?\s*[\"']([\w.]+)[\"']", f.read_text(errors="replace"))
1959
+ if m:
1960
+ return m.group(1)
1961
+ return None