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,262 @@
1
+ """Laravel / PHPUnit / Pest tests as graph nodes.
2
+
3
+ * every PHPUnit test method (`test*`, `@test`, #[Test]) of a concrete class under tests/ -> node
4
+ `test:<Class>::<method>` (kind test) with TEST_CALLS -> the method (and -> setUp());
5
+ * every Pest `test('...')` / `it('...')` (inside `describe(...)` blocks too) -> node `test:<file>::<description>`;
6
+ the calls made inside its closure are re-attributed from the file to the test node, and the test gets
7
+ TEST_CALLS -> the file (beforeEach, uses(...) and other file-level setup);
8
+ * HTTP test calls (`$this->getJson('/api/x')`, `->actingAs($u)->post(route('x.store'))`, Pest `get('/x')`,
9
+ `$this->json('PATCH', '/x')`, and the same calls through project helpers such as `$this->getAs('/x')` whose URL /
10
+ verb are parameters) -> TEST_HTTP -> the matching route (URIs matched like cross-repo links, with the
11
+ `/api` prefix Laravel adds to routes/api.php; `route('name')` by route name).
12
+
13
+ Edges made by test code are retyped to non-propagating TEST_* kinds afterwards (codegraph/tests_index.py), so
14
+ tests never change blast radius, caller counts or entry tagging. `cg tests <symbol|route>` walks them on purpose.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from collections import defaultdict
20
+
21
+ from ...core.model import EXACT, HEURISTIC, RESOLVED
22
+ from ..php.plugin import PhpFunc, is_test_path, module_of
23
+ from ..php.strings import StrEval
24
+
25
+ HTTP_VERBS = {"get": "GET", "getjson": "GET", "post": "POST", "postjson": "POST", "put": "PUT", "putjson": "PUT",
26
+ "patch": "PATCH", "patchjson": "PATCH", "delete": "DELETE", "deletejson": "DELETE", "options": "OPTIONS",
27
+ "optionsjson": "OPTIONS", "head": "HEAD"}
28
+ GENERIC_VERB = {"json", "call"} # ($method, $uri, ...)
29
+ PEST_TEST_FUNCS = {"test", "it"}
30
+
31
+
32
+ def _base(d):
33
+ while d and d.get("k") in ("mcall", "prop"):
34
+ d = d.get("of")
35
+ return d or {}
36
+
37
+
38
+ def suite_of(path: str) -> str:
39
+ m = re.search(r"(?:^|/)(?:tests|Tests)/([^/]+)/", path)
40
+ return m.group(1) if m else "tests"
41
+
42
+
43
+ class LaravelTests:
44
+ def __init__(self, lp, prog):
45
+ self.lp, self.prog = lp, prog
46
+ self.sev = StrEval(prog)
47
+ self.calls: list[tuple] = []
48
+ self.helpers: dict = defaultdict(dict) # lower method name -> {fn id: {fn, verb | vi (verb param), ui (url param)}}
49
+ self.maybe_helper: list[tuple] = []
50
+
51
+ # ---- test-code fact handler (runs on tests/ only)
52
+ def handle_fact(self, prog, fn: PhpFunc, f: dict, ctx) -> None:
53
+ if f.get("t") != "call":
54
+ return
55
+ kind = f["kind"]
56
+ m = (f.get("m") if kind == "method" else (f.get("n") or "").split("\\")[-1] if kind == "func" else None) or ""
57
+ ml = m.lower()
58
+ if kind == "method":
59
+ base = _base(f.get("recv"))
60
+ if base.get("k") != "this" and not (base.get("k") == "func" and (base.get("n") or "").split("\\")[-1].lower() in ("actingas", "withheaders", "withoutmiddleware")):
61
+ return
62
+ elif kind != "func":
63
+ return
64
+ args = f.get("args") or []
65
+ if ml == "artisan" and kind == "method" and args and args[0].get("k") == "str":
66
+ # $this->artisan('orders:prune --days=1'): the command runs in process (#60)
67
+ lb = self.lp.b
68
+ lb.add_edge(fn.id, lb.add_node("command", args[0]["v"].split(" ")[0]), "DISPATCHES", fn.file, f["line"],
69
+ EXACT, via="$this->artisan")
70
+ self.lp.stats["test_artisan_calls"] += 1
71
+ return
72
+ if ml not in HTTP_VERBS and ml not in GENERIC_VERB:
73
+ self.maybe_helper.append((fn, ml, f)) # $this->getAs('/x') through a project test helper
74
+ return
75
+ args = f.get("args") or []
76
+ if ml in GENERIC_VERB:
77
+ if len(args) < 2:
78
+ return
79
+ vd, ua = args[0], args[1]
80
+ mv = self.sev.eval(vd, fn)
81
+ verb = mv[0].upper() if mv and "{" not in mv[0] else None
82
+ else:
83
+ vd, (verb, ua) = None, (HTTP_VERBS[ml], args[0] if args else None)
84
+ if not ua:
85
+ return
86
+ ui, vi = self._param_idx(fn, ua), (self._param_idx(fn, vd) if vd is not None and not verb else None)
87
+ if ui is not None and (verb or vi is not None):
88
+ # a helper whose URL (and maybe verb) is a parameter: expanded at its call sites in contribute()
89
+ self.helpers[fn.name.lower()][fn.id] = {"fn": fn, "verb": verb, "vi": vi, "ui": ui}
90
+ return
91
+ if verb:
92
+ self._record(fn, verb, ua, f["line"], m)
93
+
94
+ @staticmethod
95
+ def _param_idx(fn: PhpFunc, d) -> int | None:
96
+ """Index of the parameter `d` is (a bare `$param` never reassigned in fn), else None."""
97
+ if not d or d.get("k") != "var":
98
+ return None
99
+ names = [p["name"] for p in fn.params]
100
+ if d.get("n") not in names or any(x.get("t") == "assign" and x.get("var") == d["n"] for x in fn.facts):
101
+ return None
102
+ return names.index(d["n"])
103
+
104
+ def _record(self, fn: PhpFunc, verb: str, ua: dict, line: int, via: str, bind=None) -> None:
105
+ if ua.get("k") == "func" and (ua.get("n") or "").split("\\")[-1].lower() == "route" and ua.get("args"):
106
+ rn = self.sev.eval(ua["args"][0], fn)
107
+ if rn and "{" not in rn[0]:
108
+ self.calls.append((fn, verb, None, rn[0], line, via))
109
+ return
110
+ if ua.get("k") == "func" and (ua.get("n") or "").split("\\")[-1].lower() == "url" and ua.get("args"):
111
+ ua = ua["args"][0]
112
+ for p in self.sev.eval(ua, fn)[:3]:
113
+ p = re.sub(r"^[a-z]+://[^/]+", "", p.split("?")[0].split("#")[0])
114
+ if not p or (not p.startswith("/") and "/" not in p and not p.startswith("{")):
115
+ continue # `$this->get('key')` on something that is not an HTTP test client
116
+ self.calls.append((fn, verb, "/" + p.lstrip("/"), None, line, via))
117
+
118
+ def _expand_helpers(self, st) -> None:
119
+ """Calls through test helpers (`$this->getAs('/x')` -> jsonAs('get', $url) -> $this->json($method, $uri)):
120
+ a call passing a literal verb / URL is recorded; one passing its own parameters makes the caller a helper too."""
121
+ done = set()
122
+ for _ in range(4):
123
+ grew = False
124
+ for fn, ml, f in self.maybe_helper:
125
+ hs = self.helpers.get(ml)
126
+ key = (fn.id, f["line"], ml)
127
+ if not hs or len(hs) != 1 or key in done:
128
+ continue
129
+ h = next(iter(hs.values()))
130
+ if h["fn"].id == fn.id:
131
+ continue
132
+ args = f.get("args") or []
133
+ ua = args[h["ui"]] if h["ui"] < len(args) else None
134
+ vd = args[h["vi"]] if h["vi"] is not None and h["vi"] < len(args) else None
135
+ if not ua:
136
+ continue
137
+ verb = h["verb"]
138
+ if not verb and vd is not None:
139
+ mv = self.sev.eval(vd, fn)
140
+ verb = mv[0].upper() if mv and "{" not in mv[0] else None
141
+ ui = self._param_idx(fn, ua)
142
+ vi = self._param_idx(fn, vd) if (vd is not None and not verb) else None
143
+ done.add(key)
144
+ if ui is not None and (verb or vi is not None):
145
+ if fn.id not in self.helpers[fn.name.lower()]:
146
+ self.helpers[fn.name.lower()][fn.id] = {"fn": fn, "verb": verb, "vi": vi, "ui": ui}
147
+ grew = True
148
+ continue
149
+ if verb:
150
+ self._record(fn, verb, ua, f["line"], f"{ml}()")
151
+ st["http_calls_via_helper"] += 1
152
+ if not grew:
153
+ break
154
+
155
+ # ---- after reference emission
156
+ def contribute(self) -> dict:
157
+ from ...link import match_endpoint
158
+ b, prog = self.lp.b, self.prog
159
+ st = defaultdict(int)
160
+ self._expand_helpers(st)
161
+ routes, by_name = [], defaultdict(list)
162
+ for nid, n in b.nodes.items():
163
+ if n.kind != "route" or not n.attrs.get("uri") or not n.attrs.get("method"):
164
+ continue
165
+ uris = [("as-declared", n.attrs["uri"])]
166
+ if (n.file or "").startswith(("routes/api.php", "routes/api/")):
167
+ uris.append(("api-prefixed", "/api" + n.attrs["uri"]))
168
+ routes.append({"id": nid, "uri": n.attrs["uri"], "method": n.attrs["method"], "uris": uris})
169
+ if n.attrs.get("name"):
170
+ by_name[n.attrs["name"]].append(n)
171
+ for fn, verb, path, rname, line, via in self.calls:
172
+ st["http_calls"] += 1
173
+ if rname:
174
+ hits = [n for n in by_name.get(rname, []) if n.attrs.get("method") in (verb, "ANY") or (verb == "HEAD" and n.attrs.get("method") == "GET")]
175
+ # one name on several routes (unnamed routes inside a named group carry just the group prefix): Laravel
176
+ # resolves it to one of them, cg cannot say which
177
+ conf = EXACT if len(hits) == 1 else HEURISTIC
178
+ if len(hits) > 3:
179
+ hits = []
180
+ st["http_calls_ambiguous_name"] += 1
181
+ for n in hits:
182
+ b.add_edge(fn.id, n.id, "TEST_HTTP", fn.file, line, conf, via=f"{via}(route('{rname}'))")
183
+ st["http_calls_matched" if hits else "http_calls_unmatched"] += 1
184
+ continue
185
+ if re.fullmatch(r"/\{[^}]*\}", path):
186
+ st["http_calls_url_unknown"] += 1 # `$this->post($url)` with $url from elsewhere: no route to name
187
+ continue
188
+ res = match_endpoint(verb, path, routes, "api")
189
+ if len(res["matched"]) > 3 and all(mm["confidence"] == HEURISTIC for mm in res["matched"]):
190
+ res["matched"] = [] # a bare suffix shared by many routes says nothing
191
+ for mm in res["matched"]:
192
+ b.add_edge(fn.id, mm["route"], "TEST_HTTP", fn.file, line, mm["confidence"], via=via, path=path)
193
+ st["http_calls_matched" if res["matched"] else "http_calls_unmatched"] += 1
194
+ st["phpunit_tests"] = self._phpunit()
195
+ st["pest_tests"] = self._pest()
196
+ return dict(st)
197
+
198
+ def _is_test_method(self, m: PhpFunc) -> bool:
199
+ if m.abstract or m.visibility != "public" or m.static:
200
+ return False
201
+ if m.name.startswith("test") or (m.doc and re.search(r"@test\b", m.doc)):
202
+ return True
203
+ return any(a.lstrip("\\").endswith("Attributes\\Test") or a == "Test" for a in m.attributes)
204
+
205
+ def _phpunit(self) -> int:
206
+ b, prog = self.lp.b, self.prog
207
+ n = 0
208
+ for fq in sorted(prog.test_classes):
209
+ c = prog.cls(fq)
210
+ if not c or c.kind != "class" or c.abstract:
211
+ continue
212
+ seen = set()
213
+ for a in prog.ancestors(fq):
214
+ ac = prog.cls(a)
215
+ if not ac or a not in prog.test_classes:
216
+ continue
217
+ for ln, m in ac.methods.items():
218
+ if ln in seen or not self._is_test_method(m):
219
+ continue
220
+ seen.add(ln)
221
+ short = fq.split("\\")[-1]
222
+ tid = b.add_node("test", f"{fq}::{m.name}", name=f"{short}::{m.name}", fqn=f"{fq}::{m.name}", file=m.file,
223
+ line=m.line, end_line=m.end_line, module=module_of(m.file), lang="php",
224
+ attrs={"framework": "phpunit", "suite": suite_of(m.file), "test": True})
225
+ b.add_edge(tid, m.id, "TEST_CALLS", m.file, m.line, EXACT, via="test method")
226
+ su = prog.find_method(fq, "setUp")
227
+ if su and su.cls in prog.test_classes:
228
+ b.add_edge(tid, su.id, "TEST_CALLS", su.file, su.line, RESOLVED, via="setUp")
229
+ n += 1
230
+ return n
231
+
232
+ def _pest(self) -> int:
233
+ b, prog = self.lp.b, self.prog
234
+ moves = defaultdict(list)
235
+ n = 0
236
+ for file, sf in prog.scripts.items():
237
+ if not is_test_path(file):
238
+ continue
239
+ calls = [f for f in sf.facts if f.get("t") == "call" and f["kind"] == "func" and f.get("args")]
240
+ describes = [(f["line"], f.get("end", f["line"]), self.sev.eval(f["args"][0], sf)[0]) for f in calls
241
+ if (f.get("n") or "").split("\\")[-1].lower() == "describe"]
242
+ used = defaultdict(int)
243
+ for f in calls:
244
+ fname = (f.get("n") or "").split("\\")[-1].lower()
245
+ if fname not in PEST_TEST_FUNCS:
246
+ continue
247
+ lo, hi = f["line"], f.get("end", f["line"])
248
+ desc = self.sev.eval(f["args"][0], sf)[0]
249
+ if fname == "it":
250
+ desc = "it " + desc
251
+ path = [d for (dl, dh, d) in sorted(describes) if dl < lo and hi <= dh] + [desc]
252
+ name = " > ".join(path)
253
+ used[name] += 1
254
+ key = f"{file}::{name}" + (f" #{used[name]}" if used[name] > 1 else "")
255
+ tid = b.add_node("test", key, name=name, fqn=key, file=file, line=lo, end_line=hi, module=module_of(file),
256
+ lang="php", attrs={"framework": "pest", "suite": suite_of(file), "test": True})
257
+ b.add_edge(tid, sf.id, "TEST_CALLS", file, lo, RESOLVED, via="file setup (beforeEach / uses)")
258
+ moves[sf.id].append((lo, hi, tid))
259
+ n += 1
260
+ if moves:
261
+ b.move_edges(moves)
262
+ return n