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,234 @@
1
+ """Member-level re-parse of a Kotlin file that tree-sitter-kotlin cannot parse (#104).
2
+
3
+ tree-sitter's error recovery often turns one construct the grammar lacks (a parenthesized function type with a
4
+ receiver, a `$$"..."` string, a context parameter) into an ERROR that swallows the rest of the class or the file.
5
+ Here the file is split into members by a small lexer (braces, parentheses and brackets outside strings, characters and
6
+ comments): top-level declarations, and recursively the members of class / object / interface bodies. Each member is
7
+ parsed on its own inside the file's skeleton (package, imports, class headers and braces; every other member blanked)
8
+ and a member that still errors is blanked in the final copy. Blanking replaces bytes with spaces and keeps newlines,
9
+ so the other members keep their lines, offsets and text, and the dropped members are reported as error spans."""
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ _DECL_START = re.compile(rb"(?:@|/\*|//|(?:(?:public|private|internal|protected|open|abstract|override|suspend|inline|"
15
+ rb"data|sealed|enum|annotation|inner|value|companion|actual|expect|operator|infix|tailrec|"
16
+ rb"external|const|lateinit|final|noinline|crossinline|fun)\b)|(?:fun|val|var|class|object|"
17
+ rb"interface|init|constructor|typealias)\b)")
18
+ _ATTACH = re.compile(rb"(?:@[\w.]+(?:\(.*\))?\s*|/\*.*|//.*|\*.*)$")
19
+ _HEADER = re.compile(rb"(?:package|import)\s")
20
+ _HEADER_CONT = re.compile(rb"(?:(?:public|private|internal|protected)\s+)?(?:@\w+\s+)*constructor\b|:|where\b")
21
+ _CLASSY = re.compile(rb"\b(?:class|object|interface)\b")
22
+ MAX_MEMBERS = 400
23
+
24
+
25
+ def _depths(src: bytes) -> tuple[list[int], list[bool]]:
26
+ """Per byte: nesting depth of ( [ { outside strings / chars / comments, and whether the byte is code."""
27
+ n = len(src)
28
+ depth = [0] * (n + 1)
29
+ code = [True] * n
30
+ d, i = 0, 0
31
+ tmpl: list[int] = [] # brace depth at which a `${` template inside a string started
32
+ while i < n:
33
+ c = src[i]
34
+ depth[i] = d
35
+ if c == 0x2F and i + 1 < n and src[i + 1] == 0x2F: # // comment
36
+ j = src.find(b"\n", i)
37
+ j = n if j < 0 else j
38
+ for k in range(i, j):
39
+ depth[k], code[k] = d, False
40
+ i = j
41
+ continue
42
+ if c == 0x2F and i + 1 < n and src[i + 1] == 0x2A: # /* nested */ comment
43
+ lvl, j = 1, i + 2
44
+ while j < n and lvl:
45
+ if src[j:j + 2] == b"/*":
46
+ lvl, j = lvl + 1, j + 2
47
+ elif src[j:j + 2] == b"*/":
48
+ lvl, j = lvl - 1, j + 2
49
+ else:
50
+ j += 1
51
+ for k in range(i, min(j, n)):
52
+ depth[k], code[k] = d, False
53
+ i = j
54
+ continue
55
+ if c == 0x27: # 'c' / '\n' / '\u0041'
56
+ m = re.match(rb"'(?:\\u[0-9a-fA-F]{4}|\\.|[^'\\\n])'", src[i:i + 8])
57
+ if m:
58
+ for k in range(i, i + m.end()):
59
+ depth[k], code[k] = d, False
60
+ i += m.end()
61
+ continue
62
+ if c == 0x22: # "..." / """..."""
63
+ raw = src[i:i + 3] == b'"""'
64
+ j = i + (3 if raw else 1)
65
+ while j < n:
66
+ if not raw and src[j] == 0x5C:
67
+ j += 2
68
+ continue
69
+ if raw and src[j:j + 3] == b'"""':
70
+ j += 3
71
+ while j < n and src[j] == 0x22:
72
+ j += 1
73
+ break
74
+ if not raw and src[j] == 0x22:
75
+ j += 1
76
+ break
77
+ if not raw and src[j] == 0x0A:
78
+ break
79
+ if src[j] == 0x24 and j + 1 < n and src[j + 1] == 0x7B: # ${ ... } template: skip balanced
80
+ lvl, j = 1, j + 2
81
+ while j < n and lvl:
82
+ if src[j] == 0x7B:
83
+ lvl += 1
84
+ elif src[j] == 0x7D:
85
+ lvl -= 1
86
+ elif src[j] == 0x22: # a string inside the template
87
+ q = src.find(b'"', j + 1)
88
+ j = n if q < 0 else q
89
+ j += 1
90
+ continue
91
+ j += 1
92
+ for k in range(i, min(j, n)):
93
+ depth[k], code[k] = d, False
94
+ i = j
95
+ continue
96
+ if c in (0x28, 0x5B, 0x7B):
97
+ d += 1
98
+ elif c in (0x29, 0x5D, 0x7D):
99
+ d = max(d - 1, 0)
100
+ i += 1
101
+ depth[n] = d
102
+ return depth, code
103
+
104
+
105
+ def _members(src: bytes, depth, code, lo: int, hi: int, d: int, out: list, level: int = 0):
106
+ """Split [lo, hi) (inside a body at depth d) into member chunks; recurse into class-like bodies."""
107
+ starts = []
108
+ pos = lo
109
+ while pos < hi:
110
+ e = src.find(b"\n", pos, hi)
111
+ e = hi if e < 0 else e
112
+ s = pos
113
+ while s < e and src[s] in (0x20, 0x09):
114
+ s += 1
115
+ if s < e and depth[s] == d and (code[s] or src[s:s + 2] in (b"/*", b"//")) and src[s] != 0x7D:
116
+ starts.append((pos, s, e))
117
+ pos = e + 1
118
+ cont = set() # `@Ann` lines in front of a `constructor(` that continues a class header
119
+ for k, (pos, s, e) in enumerate(starts):
120
+ if _HEADER_CONT.match(src[s:e]):
121
+ j = k - 1
122
+ while j >= 0 and _ATTACH.match(src[starts[j][1]:starts[j][2]].rstrip()):
123
+ cont.add(j)
124
+ j -= 1
125
+ chunks: list[list] = [] # [start, end, has_body, keep]
126
+ for k, (pos, s, e) in enumerate(starts):
127
+ line = src[s:e].rstrip()
128
+ if d == 0 and code[s] and _HEADER.match(line):
129
+ chunks.append([pos, e, True, True]) # package / import lines stay in the skeleton
130
+ continue
131
+ is_start = bool(_DECL_START.match(line))
132
+ attach = bool(_ATTACH.match(line)) or not code[s]
133
+ if (chunks and not chunks[-1][3] and (k in cont or _HEADER_CONT.match(line))
134
+ and _CLASSY.search(src[chunks[-1][0]:pos])):
135
+ is_start = False # `class A` + `private constructor(...)` / `: B()` on the next line
136
+ if chunks and not chunks[-1][3] and (not is_start or not chunks[-1][2]):
137
+ chunks[-1][1] = e
138
+ chunks[-1][2] = chunks[-1][2] or not attach
139
+ continue
140
+ chunks.append([pos, e, not attach, False])
141
+ # a chunk runs to the next one's start (its body's lines are deeper)
142
+ for k, ch in enumerate(chunks):
143
+ ch[1] = chunks[k + 1][0] if k + 1 < len(chunks) else hi
144
+ for a, b, _, keep in chunks:
145
+ if keep:
146
+ continue
147
+ # strip trailing whitespace-only region so the closing brace of the enclosing body stays outside
148
+ body = None
149
+ head_end = a
150
+ if level < 3:
151
+ j = a
152
+ while j < b:
153
+ if src[j] == 0x7B and depth[j] == d and code[j]:
154
+ body = j
155
+ break
156
+ if src[j] == 0x3D and depth[j] == d and code[j]: # `=`: an initializer, not a body
157
+ break
158
+ j += 1
159
+ if body is not None and not _CLASSY.search(_code_only(src, code, a, body)):
160
+ body = None
161
+ if body is not None:
162
+ close = body + 1
163
+ while close < b and not (src[close] == 0x7D and depth[close] == d + 1 and code[close]):
164
+ close += 1
165
+ if close < b:
166
+ head_end = body + 1
167
+ inner: list = []
168
+ _members(src, depth, code, head_end, close, d + 1, inner, level + 1)
169
+ if inner:
170
+ out.extend(inner)
171
+ continue
172
+ out.append((a, b))
173
+
174
+
175
+ def _code_only(src: bytes, code, a: int, b: int) -> bytes:
176
+ return bytes(ch for k, ch in enumerate(src[a:b]) if code[a + k])
177
+
178
+
179
+ def _blank(buf: bytearray, a: int, b: int):
180
+ for k in range(a, b):
181
+ if buf[k] not in (0x0A, 0x0D):
182
+ buf[k] = 0x20
183
+
184
+
185
+ def _err_bytes(root) -> int:
186
+ tot, stack = 0, [root]
187
+ while stack:
188
+ n = stack.pop()
189
+ if n.type == "ERROR" or n.is_missing:
190
+ tot += max(n.end_byte - n.start_byte, 1)
191
+ elif n.has_error:
192
+ stack.extend(n.children)
193
+ return tot
194
+
195
+
196
+ def reparse_members(parser, psrc: bytes, tree):
197
+ """(tree, dropped [(first line, last line)], members) for a file whose tree has errors; tree is None when the
198
+ member split does not help (the skeleton itself does not parse, or nothing would be recovered)."""
199
+ depth, code = _depths(psrc)
200
+ chunks: list = []
201
+ _members(psrc, depth, code, 0, len(psrc), 0, chunks)
202
+ if not chunks or len(chunks) > MAX_MEMBERS:
203
+ return None, [], len(chunks)
204
+ skel = bytearray(psrc)
205
+ for a, b in chunks:
206
+ _blank(skel, a, b)
207
+ if parser.parse(bytes(skel)).root_node.has_error:
208
+ return None, [], len(chunks)
209
+ bad = []
210
+ for a, b in chunks:
211
+ t = bytearray(skel)
212
+ t[a:b] = psrc[a:b]
213
+ if parser.parse(bytes(t)).root_node.has_error:
214
+ bad.append((a, b))
215
+ if not bad:
216
+ return None, [], len(chunks)
217
+ out = bytearray(psrc)
218
+ for a, b in bad:
219
+ _blank(out, a, b)
220
+ nt = parser.parse(bytes(out))
221
+ lost = sum(b - a for a, b in bad) + _err_bytes(nt.root_node)
222
+ if lost >= _err_bytes(tree.root_node):
223
+ return None, [], len(chunks)
224
+ dropped = []
225
+ for a, b in bad:
226
+ s = a
227
+ while s < b and psrc[s] in b" \t\r\n":
228
+ s += 1
229
+ e = b
230
+ while e > s and psrc[e - 1] in b" \t\r\n":
231
+ e -= 1
232
+ if e > s:
233
+ dropped.append((psrc.count(b"\n", 0, s) + 1, psrc.count(b"\n", 0, e - 1) + 1))
234
+ return nt, dropped, len(chunks)
File without changes
@@ -0,0 +1,351 @@
1
+ """Laravel broadcasting: channel authorization, broadcast events and the auth route.
2
+
3
+ * `Broadcast::channel('orders.{orderId}', callback|ChannelClass, ['guards' => ...])` -> node `channel:<pattern>`
4
+ (entry_kind channel_auth). A closure callback's calls / reads are re-attributed from the defining file to the
5
+ channel node, so `callees channel:orders.{orderId}` lists exactly the checks that decide who may join. A channel
6
+ class gets HANDLED_BY -> its join() method. The callback's first parameter (the authenticated user) is typed as
7
+ the application's User model when it carries no type.
8
+ * The broadcasting auth route: `Broadcast::routes([...])`, `->withBroadcasting(path, [...])`,
9
+ `->withRouting(channels: ...)` synthesize `POST /broadcasting/auth` (prefix + middleware, default `web`); an
10
+ explicit route to BroadcastController / `.../broadcasting/auth` is used as is. Each gets AUTHORIZES_CHANNEL ->
11
+ every declared channel.
12
+ * ShouldBroadcast(Now) events: `event:<FQCN>` (attrs broadcast, broadcast_as) with HANDLED_BY -> broadcastOn /
13
+ broadcastWith / broadcastAs / broadcastWhen, and BROADCASTS_ON -> the channel(s) named in broadcastOn() (string
14
+ names, `new Channel/PrivateChannel/PresenceChannel(...)`, helper methods that build the name, and, when the name
15
+ is a constructor argument, the value passed at each `new Event(...)` / `Event::dispatch(...)` site). Names that
16
+ match no declared pattern become `channel:<name>` nodes with declared=false.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from collections import defaultdict
22
+
23
+ from ...core.model import EXACT, HEURISTIC, RESOLVED
24
+ from ..php.plugin import PhpFunc, module_of
25
+ from ..php.strings import StrEval, channel_match, fully_dynamic, same_shape
26
+
27
+ BROADCAST_FACADES = {"illuminate\\support\\facades\\broadcast", "broadcast"}
28
+ CHANNEL_CLASSES = {"illuminate\\broadcasting\\channel": "public", "illuminate\\broadcasting\\privatechannel": "private",
29
+ "illuminate\\broadcasting\\presencechannel": "presence",
30
+ "illuminate\\broadcasting\\encryptedprivatechannel": "private-encrypted"}
31
+ SHOULD_BROADCAST = "Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast"
32
+ SHOULD_BROADCAST_NOW = "Illuminate\\Contracts\\Broadcasting\\ShouldBroadcastNow"
33
+ USER_BASE = "Illuminate\\Foundation\\Auth\\User"
34
+ AUTH_URI_RE = re.compile(r"(^|/)broadcasting/auth$")
35
+ THIS_PH = re.compile(r"\{this\.(\w+)\}")
36
+
37
+
38
+ def _classconst(d):
39
+ return d.get("class") if d and d.get("k") == "classconst" else None
40
+
41
+
42
+ def _opt(arr, key):
43
+ if not arr or arr.get("k") != "arr":
44
+ return None
45
+ for it in arr.get("items") or []:
46
+ k = it.get("key") or {}
47
+ if k.get("k") == "str" and k.get("v") == key:
48
+ return it.get("v")
49
+ return None
50
+
51
+
52
+ class BroadcastAnalysis:
53
+ def __init__(self, lp, prog):
54
+ self.lp, self.prog = lp, prog
55
+ self.sev = StrEval(prog)
56
+ self.defs: list[tuple[PhpFunc, dict]] = []
57
+ self.route_calls: list[tuple[PhpFunc, dict, str, object]] = []
58
+
59
+ # ------------------------------------------------------------------ before inference
60
+ def _user_model(self) -> str | None:
61
+ prog = self.prog
62
+ cands = [c.fqcn for c in prog.classes.values()
63
+ if c.kind == "class" and not prog.is_test_class(c.fqcn) and c.fqcn != USER_BASE and prog.is_a(c.fqcn, USER_BASE)]
64
+ if not cands:
65
+ return None
66
+ cands.sort(key=lambda q: (q.split("\\")[-1] != "User", not q.startswith("App\\Models\\"), q))
67
+ return cands[0]
68
+
69
+ def register(self) -> None:
70
+ for fn in self.prog.all_funcs:
71
+ for f in fn.facts:
72
+ if f.get("t") != "call":
73
+ continue
74
+ m = (f.get("m") or "").lower()
75
+ if f["kind"] == "static" and (f.get("class") or "").lower().lstrip("\\") in BROADCAST_FACADES:
76
+ if m == "channel" and f.get("args"):
77
+ self.defs.append((fn, f))
78
+ elif m == "routes":
79
+ self.route_calls.append((fn, f, "Broadcast::routes", 0))
80
+ elif f["kind"] == "method" and m == "withbroadcasting":
81
+ self.route_calls.append((fn, f, "->withBroadcasting", 1))
82
+ elif f["kind"] == "method" and m == "withrouting" and any(a.get("named") == "channels" for a in f.get("args") or []):
83
+ self.route_calls.append((fn, f, "->withRouting(channels:)", None))
84
+ user = self._user_model()
85
+ if not user:
86
+ return
87
+ for fn, f in self.defs:
88
+ args = f["args"]
89
+ if len(args) < 2 or args[1].get("k") != "closure":
90
+ continue
91
+ lo, hi = f["line"], f.get("end", f["line"])
92
+ ctx = next((g["ctx"] for g in fn.facts if (g.get("ctx") or {}).get("line") == lo
93
+ and (g["ctx"].get("m") or "").lower() == "channel"), None)
94
+ if not ctx or not ctx.get("cparams"):
95
+ continue
96
+ p = ctx["cparams"][0]
97
+ typed = any(g.get("t") == "vartype" and g.get("var") == p and g.get("src") == "closure_param" and lo <= g.get("line", 0) <= hi
98
+ for g in fn.facts)
99
+ if not typed:
100
+ fn.facts.append({"t": "vartype", "var": p, "types": [user], "src": "channel_user", "line": lo})
101
+
102
+ # ------------------------------------------------------------------ after reference emission
103
+ def contribute(self) -> dict:
104
+ b, prog = self.lp.b, self.prog
105
+ st = defaultdict(int)
106
+ declared: list[tuple[str, str]] = []
107
+ moves = defaultdict(list)
108
+ for fn, f in self.defs:
109
+ args = f["args"]
110
+ pats = self.sev.eval(args[0], fn)
111
+ pat = pats[0] if pats else None
112
+ if not pat or fully_dynamic(pat):
113
+ st["channels_dynamic_name"] += 1
114
+ continue
115
+ pat = THIS_PH.sub(lambda m: "{" + m.group(1) + "}", pat)
116
+ cb = args[1] if len(args) > 1 else None
117
+ lo, hi = f["line"], f.get("end", f["line"])
118
+ guards = []
119
+ g = _opt(args[2] if len(args) > 2 else None, "guards")
120
+ if g:
121
+ guards = [x for x in self.sev.eval(g, fn) if "{" not in x] if g.get("k") != "arr" else \
122
+ [it["v"]["v"] for it in g.get("items") or [] if (it.get("v") or {}).get("k") == "str"]
123
+ attrs = {"pattern": pat, "params": re.findall(r"\{(\w+)\}", pat), "declared": True}
124
+ if guards:
125
+ attrs["guards"] = guards
126
+ presence, join = False, None
127
+ if cb and cb.get("k") == "closure":
128
+ attrs["callback"] = f"closure {fn.file}:{lo}"
129
+ presence = any(x.get("t") == "return" and (x.get("expr") or {}).get("k") == "arr" and lo <= x.get("line", 0) <= hi
130
+ for x in fn.facts)
131
+ elif _classconst(cb) and prog.cls(_classconst(cb)):
132
+ cc = prog.cls(_classconst(cb))
133
+ attrs["handler"] = cc.fqcn
134
+ join = prog.find_method(cc.fqcn, "join")
135
+ presence = bool(join and any(x.get("t") == "return" and (x.get("expr") or {}).get("k") == "arr" for x in join.facts))
136
+ elif _classconst(cb):
137
+ attrs["handler"] = _classconst(cb)
138
+ attrs["visibility"] = "presence" if presence else "private"
139
+ nid = f"channel:{pat}"
140
+ if b.has(nid) and b.nodes[nid].attrs.get("declared"):
141
+ st["channels_duplicate"] += 1
142
+ b.nodes[nid].attrs.setdefault("also_defined", []).append(f"{fn.file}:{lo}")
143
+ continue
144
+ b.add_node("channel", pat, name=pat, file=fn.file, line=lo, end_line=hi, module=module_of(fn.file), lang="php",
145
+ entry_kind="channel_auth", attrs=attrs)
146
+ b.nodes[nid].entry_kind = "channel_auth"
147
+ declared.append((pat, nid))
148
+ moves[fn.id].append((lo, hi, nid))
149
+ if join:
150
+ b.add_edge(nid, join.id, "HANDLED_BY", join.file, join.line, EXACT, via="channel class")
151
+ st["channels"] += 1
152
+ if moves:
153
+ st["callback_edges_moved"] = b.move_edges(moves)
154
+ st["auth_routes"] = self._auth_routes(declared)
155
+ self._events(declared, st)
156
+ return dict(st)
157
+
158
+ def _auth_routes(self, declared) -> int:
159
+ b, prog, lp = self.lp.b, self.prog, self.lp
160
+ rids = []
161
+ for f, r in lp.records.items():
162
+ for rt in r.get("routes") or []:
163
+ act = rt.get("action") or {}
164
+ cls = (act.get("class") or "").lstrip("\\")
165
+ if cls.endswith("BroadcastController") or AUTH_URI_RE.search(rt.get("full_uri") or ""):
166
+ for verb in rt["methods"]:
167
+ rid = f"route:{verb.upper()} {rt['full_uri']}"
168
+ if b.has(rid):
169
+ b.nodes[rid].attrs["broadcast_auth"] = True
170
+ rids.append(rid)
171
+ aliases = lp._middleware_aliases()
172
+ for fn, f, via, idx in self.route_calls:
173
+ args = f.get("args") or []
174
+ opts = None
175
+ if idx is not None:
176
+ opts = next((a for a in args if a.get("named") == "attributes"), None)
177
+ if opts is None and idx < len(args) and not args[idx].get("named"):
178
+ opts = args[idx]
179
+ prefix, mws = "", ["web"]
180
+ if opts and opts.get("k") == "arr":
181
+ pv = _opt(opts, "prefix")
182
+ if pv:
183
+ prefix = (self.sev.eval(pv, fn) or [""])[0]
184
+ mv = _opt(opts, "middleware")
185
+ if mv is not None:
186
+ if mv.get("k") == "arr":
187
+ mws = [x for it in mv.get("items") or [] for x in self.sev.eval(it.get("v"), fn)[:1]]
188
+ else:
189
+ mws = self.sev.eval(mv, fn)[:1]
190
+ uri = "/" + "/".join(x.strip("/") for x in (prefix, "broadcasting/auth") if x.strip("/"))
191
+ key = f"POST {uri}"
192
+ rid = b.add_node("route", key, name=key, file=fn.file, line=f["line"], module=module_of(fn.file), lang="php",
193
+ entry_kind="http_route",
194
+ attrs={"name": None, "middleware": mws, "uri": uri, "method": "POST", "broadcast_auth": True,
195
+ "synthesized": f"{via} {fn.file}:{f['line']}"})
196
+ b.nodes[rid].entry_kind = b.nodes[rid].entry_kind or "http_route"
197
+ for mw in mws:
198
+ alias = mw.split(":")[0]
199
+ cls = aliases.get(alias) or (mw if prog.cls(mw) else None)
200
+ if cls and prog.cls(cls):
201
+ h = prog.find_method(cls, "handle")
202
+ if h:
203
+ b.add_edge(rid, h.id, "USES_MIDDLEWARE", fn.file, f["line"], RESOLVED, alias=alias)
204
+ rids.append(rid)
205
+ for rid in dict.fromkeys(rids):
206
+ for pat, nid in declared:
207
+ n = b.nodes[nid]
208
+ b.add_edge(rid, nid, "AUTHORIZES_CHANNEL", n.file, n.line, RESOLVED, via="broadcast auth")
209
+ return len(set(rids))
210
+
211
+ # ------------------------------------------------------------------ events
212
+ def _channel_news(self, cls: str, start: PhpFunc) -> list[tuple[str, str, PhpFunc, int]]:
213
+ """(pattern, visibility, fn, line) for each channel constructed / named in broadcastOn and the $this-methods
214
+ it calls (two levels)."""
215
+ out, seen, todo = [], set(), [(start, 0)]
216
+ while todo:
217
+ fn, depth = todo.pop(0)
218
+ if fn.id in seen:
219
+ continue
220
+ seen.add(fn.id)
221
+ for f in fn.facts:
222
+ t = f.get("t")
223
+ if t == "new" and (f.get("class") or "").lower().lstrip("\\") in CHANNEL_CLASSES and f.get("args"):
224
+ vis = CHANNEL_CLASSES[f["class"].lower().lstrip("\\")]
225
+ for p in self.sev.eval(f["args"][0], fn):
226
+ out.append((p, vis, fn, f["line"]))
227
+ elif t == "return" and fn is start and not f.get("ctx"):
228
+ e = f.get("expr") or {}
229
+ items = [it.get("v") for it in e.get("items") or []] if e.get("k") == "arr" else [e]
230
+ for it in items:
231
+ if it and it.get("k") in ("str", "interp", "concat"):
232
+ for p in self.sev.eval(it, fn):
233
+ out.append((p, "public", fn, f["line"]))
234
+ elif t == "call" and f["kind"] == "method" and (f.get("recv") or {}).get("k") == "this" and depth < 2:
235
+ m = self.prog.find_method(cls, f.get("m"))
236
+ if m and not m.abstract:
237
+ todo.append((m, depth + 1))
238
+ return out
239
+
240
+ def _sites(self, cls: str, prop: str) -> list[tuple[str, PhpFunc, int]]:
241
+ """Values of constructor argument `prop` at each `new Cls(...)` / `Cls::dispatch(...)` site."""
242
+ ctor = self.prog.find_method(cls, "__construct")
243
+ if not ctor:
244
+ return []
245
+ idx = next((i for i, p in enumerate(ctor.params) if p["name"] == prop), None)
246
+ if idx is None:
247
+ return []
248
+ out = []
249
+ for fn in self.prog.all_funcs:
250
+ for f in fn.facts:
251
+ hit = (f.get("t") == "new" and f.get("class") == cls) or \
252
+ (f.get("t") == "call" and f["kind"] == "static" and f.get("class") == cls
253
+ and (f.get("m") or "").lower() in ("dispatch", "broadcast", "dispatchif", "dispatchunless"))
254
+ if not hit:
255
+ continue
256
+ args = f.get("args") or []
257
+ a = next((x for x in args if x.get("named") == prop), None)
258
+ if a is None and idx < len(args) and not args[idx].get("named"):
259
+ a = args[idx]
260
+ if a is None:
261
+ continue
262
+ for p in self.sev.eval(a, fn):
263
+ out.append((p, fn, f["line"]))
264
+ return out
265
+
266
+ def _events(self, declared, st):
267
+ b, prog = self.lp.b, self.prog
268
+ bevents = {}
269
+ for c in list(prog.classes.values()):
270
+ if c.kind != "class" or c.abstract or prog.is_test_class(c.fqcn):
271
+ continue
272
+ now = prog.is_a(c.fqcn, SHOULD_BROADCAST_NOW)
273
+ if not (now or prog.is_a(c.fqcn, SHOULD_BROADCAST)):
274
+ continue
275
+ attrs = {"broadcast": True}
276
+ if now:
277
+ attrs["broadcast_now"] = True
278
+ ba = prog.find_method(c.fqcn, "broadcastAs")
279
+ if ba:
280
+ names = [x for r in ba.facts if r.get("t") == "return" and not r.get("ctx") for x in self.sev.eval(r["expr"], ba)]
281
+ if names and "{" not in names[0]:
282
+ attrs["broadcast_as"] = names[0]
283
+ eid = b.add_node("event", c.fqcn, name=c.fqcn.split("\\")[-1], fqn=c.fqcn, file=c.file, line=c.line,
284
+ end_line=c.end_line, module=module_of(c.file), lang="php", attrs=attrs)
285
+ n = b.nodes[eid]
286
+ n.file, n.line = n.file or c.file, n.line or c.line
287
+ bevents[f"{c.kind}:{c.fqcn}"] = eid
288
+ st["broadcast_events"] += 1
289
+ for mn in ("broadcastOn", "broadcastWith", "broadcastAs", "broadcastWhen"):
290
+ m = prog.find_method(c.fqcn, mn)
291
+ if m and not m.abstract:
292
+ b.add_edge(eid, m.id, "HANDLED_BY", m.file, m.line, EXACT, via="broadcast")
293
+ bo = prog.find_method(c.fqcn, "broadcastOn")
294
+ if not bo or bo.abstract:
295
+ continue
296
+ unresolved = []
297
+ for pat, vis, fn, line in self._channel_news(c.fqcn, bo):
298
+ resolved = []
299
+ props = THIS_PH.findall(pat)
300
+ if fully_dynamic(pat) and len(props) == 1:
301
+ for val, sfn, sline in self._sites(c.fqcn, props[0]):
302
+ resolved.append((THIS_PH.sub(lambda m: "{" + m.group(1) + "}", pat.replace("{this." + props[0] + "}", val)),
303
+ {"site": f"{sfn.file}:{sline}", "site_fn": sfn.id}))
304
+ if not resolved:
305
+ resolved = [(THIS_PH.sub(lambda m: "{" + m.group(1) + "}", pat), {})]
306
+ # `{?}` (unknown) alternatives of a name that also resolved to a named placeholder add nothing
307
+ resolved = [(nm, ex) for nm, ex in resolved
308
+ if "{?}" not in nm or not any(o != nm and "{?}" not in o and same_shape(nm, o) and ex == oe
309
+ for o, oe in resolved)]
310
+ for name, extra in resolved:
311
+ if fully_dynamic(name):
312
+ unresolved.append(name)
313
+ st["channel_names_unresolved"] += 1
314
+ continue
315
+ hits = [nid for p, nid in declared if same_shape(name, p)]
316
+ conf = RESOLVED
317
+ if not hits:
318
+ hits = [nid for p, nid in declared if channel_match(name, p)]
319
+ conf = RESOLVED if len(hits) == 1 else HEURISTIC
320
+ if not hits:
321
+ nid = b.add_node("channel", name, name=name, lang="php",
322
+ attrs={"pattern": name, "declared": False, "visibility": vis})
323
+ hits = [nid]
324
+ conf = RESOLVED
325
+ st["channels_undeclared"] += 1
326
+ ef, el = (extra["site"].rsplit(":", 1)[0], int(extra["site"].rsplit(":", 1)[1])) if extra else (fn.file, line)
327
+ for nid in hits:
328
+ b.add_edge(eid, nid, "BROADCASTS_ON", ef, el, conf, name=name, visibility=vis, at=f"{fn.file}:{line}", **extra)
329
+ st["broadcasts_on"] += 1
330
+ if unresolved:
331
+ n.attrs["unresolved_channels"] = sorted(set(unresolved))
332
+ # a callback whose return is not an obvious array (arrow fn returning a model / query) still serves a presence
333
+ # channel when every event publishes on it as a PresenceChannel
334
+ pub_vis = {}
335
+ for e in b.edges.values():
336
+ if e.kind == "BROADCASTS_ON":
337
+ pub_vis.setdefault(e.dst, set()).add(e.attrs.get("visibility"))
338
+ for nid, vs in pub_vis.items():
339
+ n = b.nodes.get(nid)
340
+ if n and n.attrs.get("declared") and n.attrs.get("visibility") == "private" and vs == {"presence"}:
341
+ n.attrs["visibility"] = "presence"
342
+ n.attrs["visibility_from"] = "publishers (PresenceChannel)"
343
+ # an instantiated broadcast event is (almost always) broadcast: `$e = new X(...); broadcast($e)->toOthers()`
344
+ have = {(e.src, e.dst) for e in b.edges.values() if e.kind == "DISPATCHES"}
345
+ for e in list(b.edges.values()):
346
+ if e.kind == "INSTANTIATES" and e.dst in bevents and (e.src, bevents[e.dst]) not in have:
347
+ if e.src.startswith("method:") and e.src.split(":", 1)[1].split("::")[0] == e.dst.split(":", 1)[1]:
348
+ continue
349
+ b.add_edge(e.src, bevents[e.dst], "DISPATCHES", e.file, e.line, RESOLVED, via="instantiation (broadcast event)")
350
+ have.add((e.src, bevents[e.dst]))
351
+ st["dispatches_from_instantiation"] += 1