codegraph-engine 3.0.0__2-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 (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +868 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
codegraph/roblox.py ADDED
@@ -0,0 +1,2174 @@
1
+ """Roblox & Luau Codebase Intelligence Engine (Phases R1–R9).
2
+
3
+ Provides deterministic, local-first static analysis for Roblox / Luau projects:
4
+ - Phase R1: Robust Luau parser (.lua, .luau) for functions, methods (: and .), tables,
5
+ module exports, table.freeze, Luau types, exported types, and type annotations.
6
+ - Phase R2: Rojo project resolution (default.project.json parsed strictly as JSON) mapping
7
+ ReplicatedStorage, ServerScriptService, StarterPlayer.StarterPlayerScripts,
8
+ ServerStorage, Workspace, and ReplicatedFirst to physical repository files.
9
+ - Phase R3: Module / require(...) graph (REQUIRES_MODULE) with bounded script.Parent and
10
+ Rojo alias resolution.
11
+ - Phase R4: Client <-> Server Network Graph (CLIENT_DISPATCHES_REMOTE, SERVER_HANDLES_REMOTE)
12
+ for RemoteEvent and RemoteFunction.
13
+ - Phase R6: Knit framework support (PROVIDES_SERVICE, GETS_SERVICE).
14
+ - Phase R7: Flamework TypeScript support (@Service, @Controller, Dependency<T>).
15
+ - Phase R8: Roblox persistence intelligence (DataStoreService, ProfileService ->
16
+ CONFIGURES_PERSISTENCE, READS_PERSISTENCE, WRITES_PERSISTENCE).
17
+ - Phase R9: CLI and MCP query helpers (remotes, modules, routes).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import posixpath
24
+ import re
25
+ import sqlite3
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING, Any
29
+
30
+ if TYPE_CHECKING:
31
+ from codegraph.indexing.parser import ParseResult
32
+
33
+ from codegraph.epistemic import RelationshipEvidenceClass
34
+ from codegraph.errors import SecurityError
35
+ from codegraph.graph.models import GraphEdge
36
+ from codegraph.indexing.models import (
37
+ BindingRef,
38
+ CallRef,
39
+ ImportRef,
40
+ Reference,
41
+ Symbol,
42
+ build_canonical_id,
43
+ normalize_module,
44
+ )
45
+ from codegraph.security import safe_path
46
+
47
+ _ROBLOX_DATAMODEL_SERVICES = frozenset({
48
+ "ReplicatedStorage",
49
+ "ServerScriptService",
50
+ "StarterPlayer",
51
+ "StarterPlayerScripts",
52
+ "StarterCharacterScripts",
53
+ "StarterGui",
54
+ "StarterPack",
55
+ "ServerStorage",
56
+ "Workspace",
57
+ "ReplicatedFirst",
58
+ "Players",
59
+ "DataStoreService",
60
+ "MemoryStoreService",
61
+ "MessagingService",
62
+ "HttpService",
63
+ "RunService",
64
+ "UserInputService",
65
+ "TweenService",
66
+ "CollectionService",
67
+ "MarketplaceService",
68
+ "SoundService",
69
+ "Lighting",
70
+ "Teams",
71
+ "Chat",
72
+ "TextChatService",
73
+ })
74
+
75
+ _LUAU_KEYWORDS_AND_BUILTINS = frozenset({
76
+ "and", "break", "do", "else", "elseif", "end", "false", "for", "function",
77
+ "if", "in", "local", "nil", "not", "or", "repeat", "return", "then",
78
+ "true", "until", "while", "continue", "type", "export", "typeof",
79
+ "print", "warn", "error", "assert", "pcall", "xpcall", "select", "tonumber",
80
+ "tostring", "ipairs", "pairs", "next", "rawget", "rawset", "rawequal",
81
+ "setmetatable", "getmetatable", "unpack", "require", "math", "string",
82
+ "table", "coroutine", "os", "debug", " utf8", "bit32", "task", "vector",
83
+ "Vector3", "Vector2", "CFrame", "Color3", "UDim", "UDim2", "Instance",
84
+ "Enum", " RaycastParams", "BrickColor", "NumberRange", "NumberSequence",
85
+ "ColorSequence", "Rect", "Region3", "TweenInfo", "wait", "delay", "spawn",
86
+ })
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Phase R2: Rojo Project Resolution (default.project.json)
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class RojoMapping:
95
+ virtual_path: str # e.g. "ReplicatedStorage.Packages" or "ServerScriptService.Services"
96
+ physical_path: str # repo-relative posix path e.g. "src/shared/Packages"
97
+
98
+
99
+ @dataclass
100
+ class RojoProject:
101
+ name: str = "default"
102
+ mappings: list[RojoMapping] = field(default_factory=list)
103
+ is_valid: bool = False
104
+ parse_error: str | None = None
105
+ project_file: str = "default.project.json"
106
+
107
+ @classmethod
108
+ def load(cls, repository: Path | None) -> RojoProject:
109
+ """Parse default.project.json strictly as JSON without executing anything."""
110
+ if repository is None:
111
+ return cls(is_valid=False)
112
+ try:
113
+ root = repository.resolve(strict=True)
114
+ except OSError:
115
+ return cls(is_valid=False)
116
+
117
+ candidates = ["default.project.json"]
118
+ try:
119
+ for item in sorted(root.iterdir()):
120
+ if item.is_file() and item.name.endswith(".project.json") and item.name != "default.project.json":
121
+ candidates.append(item.name)
122
+ except OSError:
123
+ pass
124
+
125
+ project_path: Path | None = None
126
+ rel_project_name = "default.project.json"
127
+ for cand in candidates:
128
+ try:
129
+ resolved = safe_path(root, cand)
130
+ if resolved.is_file():
131
+ project_path = resolved
132
+ rel_project_name = cand
133
+ break
134
+ except SecurityError:
135
+ continue
136
+
137
+ if project_path is None:
138
+ return cls(is_valid=False)
139
+
140
+ try:
141
+ raw_text = project_path.read_text(encoding="utf-8", errors="replace")
142
+ data = json.loads(raw_text)
143
+ except Exception as exc:
144
+ return cls(
145
+ is_valid=False,
146
+ parse_error=f"Invalid JSON in {rel_project_name}: {exc}",
147
+ project_file=rel_project_name,
148
+ )
149
+
150
+ if not isinstance(data, dict):
151
+ return cls(
152
+ is_valid=False,
153
+ parse_error=f"Root of {rel_project_name} must be a JSON object",
154
+ project_file=rel_project_name,
155
+ )
156
+
157
+ proj_name = str(data.get("name", "default"))
158
+ tree = data.get("tree")
159
+ if not isinstance(tree, dict):
160
+ return cls(
161
+ name=proj_name,
162
+ is_valid=False,
163
+ parse_error=f"Missing or invalid 'tree' object in {rel_project_name}",
164
+ project_file=rel_project_name,
165
+ )
166
+
167
+ mappings: list[RojoMapping] = []
168
+
169
+ def _walk_tree(node: dict[str, Any], vpath_parts: list[str]) -> None:
170
+ raw_path = node.get("$path")
171
+ if isinstance(raw_path, str) and raw_path.strip():
172
+ try:
173
+ resolved_phys = safe_path(root, raw_path.strip())
174
+ rel_phys = resolved_phys.relative_to(root).as_posix()
175
+ vpath = ".".join(vpath_parts) if vpath_parts else "game"
176
+ mappings.append(RojoMapping(virtual_path=vpath, physical_path=rel_phys))
177
+ except (SecurityError, ValueError, OSError):
178
+ # Block path traversal outside repository boundary
179
+ pass
180
+
181
+ for key, child in sorted(node.items()):
182
+ if key.startswith("$") or not isinstance(child, dict):
183
+ continue
184
+ next_parts = [*vpath_parts, key]
185
+ _walk_tree(child, next_parts)
186
+ # Also create StarterPlayerScripts shortcut if under StarterPlayer.StarterPlayerScripts
187
+ if next_parts == ["StarterPlayer", "StarterPlayerScripts"]:
188
+ child_path = child.get("$path")
189
+ if isinstance(child_path, str) and child_path.strip():
190
+ try:
191
+ resolved_sp = safe_path(root, child_path.strip())
192
+ rel_sp = resolved_sp.relative_to(root).as_posix()
193
+ mappings.append(
194
+ RojoMapping(
195
+ virtual_path="StarterPlayerScripts",
196
+ physical_path=rel_sp,
197
+ )
198
+ )
199
+ except (SecurityError, ValueError, OSError):
200
+ pass
201
+
202
+ _walk_tree(tree, [])
203
+ # Sort longest virtual_path first so most specific Rojo mapping wins
204
+ mappings.sort(key=lambda m: (-len(m.virtual_path), m.virtual_path))
205
+ return cls(
206
+ name=proj_name,
207
+ mappings=mappings,
208
+ is_valid=True,
209
+ project_file=rel_project_name,
210
+ )
211
+
212
+ def resolve_virtual_to_file(
213
+ self,
214
+ virtual_expr: str,
215
+ known_files: set[str],
216
+ ) -> tuple[str | None, str]:
217
+ """Resolve a virtual Roblox DataModel path (e.g. 'ReplicatedStorage.Packages.Knit')
218
+ to a physical repository file path in `known_files`.
219
+
220
+ Returns (resolved_file_path, evidence_class).
221
+ """
222
+ clean = virtual_expr.strip()
223
+ if clean.startswith("game."):
224
+ clean = clean[len("game.") :]
225
+
226
+ if not self.is_valid or not self.mappings:
227
+ return None, RelationshipEvidenceClass.UNKNOWN.value
228
+
229
+ for mapping in self.mappings:
230
+ vp = mapping.virtual_path
231
+ if clean == vp or clean.startswith(vp + "."):
232
+ remainder = clean[len(vp) :].lstrip(".")
233
+ rel_sub = remainder.replace(".", "/") if remainder else ""
234
+ base_phys = (
235
+ posixpath.normpath(posixpath.join(mapping.physical_path, rel_sub))
236
+ if rel_sub
237
+ else posixpath.normpath(mapping.physical_path)
238
+ )
239
+ if base_phys.startswith(".."):
240
+ continue
241
+
242
+ candidates = [
243
+ base_phys,
244
+ f"{base_phys}.luau",
245
+ f"{base_phys}.lua",
246
+ f"{base_phys}.server.luau",
247
+ f"{base_phys}.client.luau",
248
+ f"{base_phys}.server.lua",
249
+ f"{base_phys}.client.lua",
250
+ f"{base_phys}/init.luau",
251
+ f"{base_phys}/init.lua",
252
+ f"{base_phys}/init.server.luau",
253
+ f"{base_phys}/init.client.luau",
254
+ f"{base_phys}/init.server.lua",
255
+ f"{base_phys}/init.client.lua",
256
+ f"{base_phys}.ts",
257
+ f"{base_phys}.tsx",
258
+ f"{base_phys}/index.ts",
259
+ f"{base_phys}/index.tsx",
260
+ ]
261
+ for cand in candidates:
262
+ norm = cand.lstrip("./")
263
+ if norm in known_files:
264
+ return norm, RelationshipEvidenceClass.ROJO_VERIFIED.value
265
+
266
+ return None, RelationshipEvidenceClass.UNKNOWN.value
267
+
268
+ def file_to_virtual_path(self, file_path: str) -> str | None:
269
+ """Map a physical repository file path back to its Rojo virtual DataModel path."""
270
+ if not self.is_valid or not self.mappings:
271
+ return None
272
+ clean = file_path.replace("\\", "/").lstrip("./")
273
+ for ext in (
274
+ "/init.server.luau",
275
+ "/init.client.luau",
276
+ "/init.server.lua",
277
+ "/init.client.lua",
278
+ "/init.luau",
279
+ "/init.lua",
280
+ ".server.luau",
281
+ ".client.luau",
282
+ ".server.lua",
283
+ ".client.lua",
284
+ ".luau",
285
+ ".lua",
286
+ "/index.ts",
287
+ "/index.tsx",
288
+ ".ts",
289
+ ".tsx",
290
+ ):
291
+ if clean.lower().endswith(ext):
292
+ clean = clean[: -len(ext)]
293
+ break
294
+
295
+ for mapping in self.mappings:
296
+ mp = mapping.physical_path.rstrip("/")
297
+ if clean == mp:
298
+ return mapping.virtual_path
299
+ if clean.startswith(mp + "/"):
300
+ rem = clean[len(mp) + 1 :].replace("/", ".")
301
+ return f"{mapping.virtual_path}.{rem}" if rem else mapping.virtual_path
302
+ return None
303
+
304
+
305
+ def resolve_luau_relative_script_path(
306
+ script_expr: str,
307
+ source_file: str,
308
+ known_files: set[str],
309
+ rojo: RojoProject | None = None,
310
+ ) -> tuple[str | None, str]:
311
+ """Deterministically resolve `script.Parent...` expressions within repository boundaries.
312
+
313
+ In Roblox:
314
+ - For a regular script `src/server/Services/PlayerService.luau`, `script` is the file itself,
315
+ and `script.Parent` is the directory `src/server/Services`.
316
+ - For an `init.luau` / `init.lua` file `src/shared/MyPkg/init.luau`, `script` is `src/shared/MyPkg`,
317
+ and `script.Parent` is `src/shared`.
318
+ """
319
+ clean_expr = script_expr.strip()
320
+ if not clean_expr.startswith("script"):
321
+ return None, RelationshipEvidenceClass.UNKNOWN.value
322
+
323
+ src_clean = source_file.replace("\\", "/").lstrip("./")
324
+ is_init_script = any(
325
+ src_clean.lower().endswith(suffix)
326
+ for suffix in (
327
+ "/init.luau",
328
+ "/init.lua",
329
+ "/init.server.luau",
330
+ "/init.client.luau",
331
+ "/init.server.lua",
332
+ "/init.client.lua",
333
+ )
334
+ )
335
+ current_dir = posixpath.dirname(src_clean) if is_init_script else src_clean
336
+
337
+ parts = [p.strip() for p in clean_expr.split(".") if p.strip()]
338
+ if not parts or parts[0] != "script":
339
+ return None, RelationshipEvidenceClass.UNKNOWN.value
340
+
341
+ for idx, part in enumerate(parts[1:], start=1):
342
+ if part == "Parent":
343
+ if not current_dir or current_dir in (".", "/"):
344
+ # Escapes repository boundary!
345
+ return None, RelationshipEvidenceClass.UNKNOWN.value
346
+ # If current_dir is a file path (at step 1 for non-init script), dirname gives its folder
347
+ if idx == 1 and not is_init_script:
348
+ current_dir = posixpath.dirname(src_clean)
349
+ else:
350
+ parent_dir = posixpath.dirname(current_dir)
351
+ if parent_dir == current_dir:
352
+ return None, RelationshipEvidenceClass.UNKNOWN.value
353
+ current_dir = parent_dir
354
+ else:
355
+ # Descending into child module/folder
356
+ if idx == 1 and not is_init_script:
357
+ # `script.Child` on a non-init file: check sibling folder with same stem or current folder
358
+ stem = src_clean
359
+ for ext in (".server.luau", ".client.luau", ".server.lua", ".client.lua", ".luau", ".lua"):
360
+ if stem.lower().endswith(ext):
361
+ stem = stem[: -len(ext)]
362
+ break
363
+ current_dir = posixpath.join(stem, part)
364
+ else:
365
+ current_dir = posixpath.join(current_dir, part) if current_dir else part
366
+
367
+ norm_base = posixpath.normpath(current_dir) if current_dir else ""
368
+ if norm_base.startswith("..") or norm_base.startswith("/"):
369
+ return None, RelationshipEvidenceClass.UNKNOWN.value
370
+
371
+ candidates = [
372
+ norm_base,
373
+ f"{norm_base}.luau",
374
+ f"{norm_base}.lua",
375
+ f"{norm_base}/init.luau",
376
+ f"{norm_base}/init.lua",
377
+ ]
378
+ ev_cls = (
379
+ RelationshipEvidenceClass.ROJO_VERIFIED.value
380
+ if (rojo and rojo.is_valid)
381
+ else RelationshipEvidenceClass.AST_VERIFIED.value
382
+ )
383
+ for cand in candidates:
384
+ c_clean = cand.lstrip("./")
385
+ if c_clean in known_files:
386
+ return c_clean, ev_cls
387
+
388
+ return None, RelationshipEvidenceClass.UNKNOWN.value
389
+
390
+
391
+ # ---------------------------------------------------------------------------
392
+ # Phase R1: First-Class Luau Parser (.lua, .luau)
393
+ # ---------------------------------------------------------------------------
394
+
395
+ _LUA_COMMENT_LINE_RE = re.compile(r"--(?!\[\[).*$")
396
+ _LUA_LOCAL_FUNC_RE = re.compile(
397
+ r"^\s*local\s+function\s+([A-Za-z_]\w*)\s*(?:<[^>]*>)?\s*\(([^)]*)\)\s*(?::\s*(.+))?$"
398
+ )
399
+ _LUA_GLOBAL_FUNC_RE = re.compile(
400
+ r"^\s*function\s+([A-Za-z_]\w*)\s*(?:<[^>]*>)?\s*\(([^)]*)\)\s*(?::\s*(.+))?$"
401
+ )
402
+ _LUA_METHOD_DECL_RE = re.compile(
403
+ r"^\s*function\s+([A-Za-z_][\w.]*)([.:])([A-Za-z_]\w*)\s*(?:<[^>]*>)?\s*\(([^)]*)\)\s*(?::\s*(.+))?$"
404
+ )
405
+ _LUA_ASSIGN_FUNC_RE = re.compile(
406
+ r"^\s*([A-Za-z_][\w.]*)\s*=\s*function\s*(?:<[^>]*>)?\s*\(([^)]*)\)\s*(?::\s*(.+))?$"
407
+ )
408
+ _LUA_LOCAL_ASSIGN_FUNC_RE = re.compile(
409
+ r"^\s*local\s+([A-Za-z_]\w*)\s*(?::\s*[^=]+)?=\s*function\s*(?:<[^>]*>)?\s*\(([^)]*)\)\s*(?::\s*(.+))?$"
410
+ )
411
+ _LUA_TYPE_DECL_RE = re.compile(
412
+ r"^\s*(export\s+)?type\s+([A-Za-z_]\w*)(?:<[^>]*>)?\s*=\s*(.+)$"
413
+ )
414
+ _LUA_TABLE_DECL_RE = re.compile(
415
+ r"^\s*(?:local\s+)?([A-Za-z_]\w*)\s*(?::\s*([^=]+))?=\s*\{\s*\}"
416
+ )
417
+ _LUA_TYPED_VAR_RE = re.compile(
418
+ r"^\s*local\s+([A-Za-z_]\w*)\s*:\s*([^=]+?)(?:\s*=\s*(.+))?$"
419
+ )
420
+ _LUA_RETURN_FREEZE_RE = re.compile(
421
+ r"^\s*return\s+table\.freeze\(\s*([A-Za-z_]\w*)\s*\)"
422
+ )
423
+ _LUA_RETURN_IDENT_RE = re.compile(
424
+ r"^\s*return\s+([A-Za-z_][\w.]*)\s*$"
425
+ )
426
+ _LUA_REQUIRE_ASSIGN_RE = re.compile(
427
+ r"^\s*(?:local\s+)?([A-Za-z_]\w*)\s*(?::\s*[^=]+)?=\s*require\(\s*([^)]+?)\s*\)"
428
+ )
429
+ _LUA_BARE_REQUIRE_RE = re.compile(
430
+ r"\brequire\(\s*([^)]+?)\s*\)"
431
+ )
432
+ _LUA_GET_SERVICE_RE = re.compile(
433
+ r"^\s*local\s+([A-Za-z_]\w*)\s*(?::\s*[^=]+)?=\s*game:GetService\(\s*['\"]([A-Za-z_]\w*)['\"]\s*\)"
434
+ )
435
+ _LUA_ALIAS_CHAIN_RE = re.compile(
436
+ r"^\s*local\s+([A-Za-z_]\w*)\s*(?::\s*[^=]+)?=\s*([A-Za-z_][\w.:()\"'\s]*)$"
437
+ )
438
+ _LUA_KNIT_CREATE_SERVICE_RE = re.compile(
439
+ r"(?:local\s+([A-Za-z_]\w*)\s*=\s*)?Knit\.CreateService\(\s*\{([^}]*)\}"
440
+ )
441
+ _LUA_KNIT_CREATE_CONTROLLER_RE = re.compile(
442
+ r"(?:local\s+([A-Za-z_]\w*)\s*=\s*)?Knit\.CreateController\(\s*\{([^}]*)\}"
443
+ )
444
+ _LUA_KNIT_GET_SERVICE_RE = re.compile(
445
+ r"(?:local\s+([A-Za-z_]\w*)\s*=\s*)?Knit\.GetService\(\s*([^)]+?)\s*\)"
446
+ )
447
+ _LUA_KNIT_GET_CONTROLLER_RE = re.compile(
448
+ r"(?:local\s+([A-Za-z_]\w*)\s*=\s*)?Knit\.GetController\(\s*([^)]+?)\s*\)"
449
+ )
450
+ _LUA_REMOTE_FIRE_RE = re.compile(
451
+ r"([A-Za-z_][\w.:()\"']*?):(FireServer|InvokeServer|FireClient|FireAllClients)\s*\(([^)]*)\)"
452
+ )
453
+ _LUA_REMOTE_CONNECT_RE = re.compile(
454
+ r"([A-Za-z_][\w.:()\"']*?)\.(OnServerEvent|OnClientEvent):Connect\s*\(\s*([^)]+?)\s*\)"
455
+ )
456
+ _LUA_REMOTE_INVOKE_ASSIGN_RE = re.compile(
457
+ r"^\s*([A-Za-z_][\w.:()\"']*?)\.(OnServerInvoke|OnClientInvoke)\s*=\s*(.+)$"
458
+ )
459
+ _LUA_GET_DATASTORE_RE = re.compile(
460
+ r"^\s*(?:local\s+)?([A-Za-z_][\w.]*)\s*=\s*([A-Za-z_][\w.:()\"']*?):(GetDataStore|GetOrderedDataStore)\s*\(\s*([^)]+?)\s*\)"
461
+ )
462
+ _LUA_PROFILESERVICE_STORE_RE = re.compile(
463
+ r"^\s*(?:local\s+)?([A-Za-z_][\w.]*)\s*=\s*([A-Za-z_]\w*)\.GetProfileStore\s*\(\s*([^,)]+)"
464
+ )
465
+ _LUA_DATASTORE_OP_RE = re.compile(
466
+ r"\b([A-Za-z_][\w.]*):(GetAsync|SetAsync|UpdateAsync|IncrementAsync|RemoveAsync|LoadProfileAsync)\s*\("
467
+ )
468
+ _LUA_CALL_SITE_RE = re.compile(
469
+ r"\b(?:([A-Za-z_][\w.]*)([.:]))?([A-Za-z_]\w*)\s*\("
470
+ )
471
+
472
+
473
+ def _strip_luau_comment(line: str) -> str:
474
+ """Strip single-line `--` comments outside string literals."""
475
+ in_single = False
476
+ in_double = False
477
+ i = 0
478
+ n = len(line)
479
+ while i < n:
480
+ ch = line[i]
481
+ if ch == "'" and not in_double:
482
+ in_single = not in_single
483
+ elif ch == '"' and not in_single:
484
+ in_double = not in_double
485
+ elif ch == "-" and i + 1 < n and line[i + 1] == "-" and not in_single and not in_double:
486
+ return line[:i]
487
+ i += 1
488
+ return line
489
+
490
+
491
+ def _normalize_roblox_chain(expr: str, local_aliases: dict[str, str]) -> tuple[str, bool]:
492
+ """Normalize a Roblox instance access chain (including WaitForChild / FindFirstChild and local aliases).
493
+
494
+ Returns (normalized_dotted_chain, is_dynamic).
495
+ Example:
496
+ `game:GetService("ReplicatedStorage"):WaitForChild("Remotes"):WaitForChild("Inventory")`
497
+ -> (`ReplicatedStorage.Remotes.Inventory`, False)
498
+ """
499
+ raw = expr.strip()
500
+ if not raw:
501
+ return "", True
502
+
503
+ # Replace game:GetService("X") with X
504
+ norm = re.sub(
505
+ r"""game:GetService\(\s*['"]([A-Za-z_]\w*)['"]\s*\)""",
506
+ r"\1",
507
+ raw,
508
+ )
509
+
510
+ # Check if WaitForChild or FindFirstChild has a non-literal argument
511
+ for m_wait in re.finditer(r""":(?:WaitForChild|FindFirstChild)\(\s*([^)]+?)\s*\)""", norm):
512
+ arg = m_wait.group(1).split(",")[0].strip()
513
+ if not ((arg.startswith('"') and arg.endswith('"')) or (arg.startswith("'") and arg.endswith("'"))):
514
+ return norm, True
515
+
516
+ # Replace :WaitForChild("X") and :FindFirstChild("X") with .X
517
+ norm = re.sub(
518
+ r""":(?:WaitForChild|FindFirstChild)\(\s*['"]([^'"]+)['"](?:\s*,[^)]*)?\s*\)""",
519
+ r".\1",
520
+ norm,
521
+ )
522
+
523
+ # Check for bracket indexing: e.g. Remotes["Inventory"] vs Remotes[dynamicVar]
524
+ for m_idx in re.finditer(r"""\[\s*([^]]+?)\s*\]""", norm):
525
+ idx_arg = m_idx.group(1).strip()
526
+ if (idx_arg.startswith('"') and idx_arg.endswith('"')) or (idx_arg.startswith("'") and idx_arg.endswith("'")):
527
+ lit = idx_arg[1:-1]
528
+ norm = norm.replace(m_idx.group(0), f".{lit}")
529
+ else:
530
+ return norm, True
531
+
532
+ # If any function call parens remain, it's dynamic unless it was resolved
533
+ if "(" in norm or ")" in norm or ".." in norm:
534
+ return norm, True
535
+
536
+ parts = [p.strip() for p in norm.split(".") if p.strip()]
537
+ if not parts:
538
+ return "", True
539
+
540
+ # Expand leading alias iteratively (bounded to 8 steps to prevent cycles)
541
+ for _ in range(8):
542
+ head = parts[0]
543
+ if head in local_aliases:
544
+ alias_target = local_aliases[head]
545
+ if alias_target.startswith("<DYNAMIC"):
546
+ return f"{alias_target}.{'.'.join(parts[1:])}", True
547
+ alias_parts = [p for p in alias_target.split(".") if p]
548
+ parts = [*alias_parts, *parts[1:]]
549
+ else:
550
+ break
551
+
552
+ return ".".join(parts), False
553
+
554
+
555
+ def _estimate_luau_block_end(lines: list[str], start_idx: int) -> int:
556
+ """Estimate the closing `end` line of a Luau function/block starting at `start_idx`."""
557
+ depth = 0
558
+ opened = False
559
+ open_pattern = re.compile(r"\b(?:function|if|for|while|repeat|do)\b")
560
+ close_pattern = re.compile(r"\b(?:end|until)\b")
561
+
562
+ for i in range(start_idx, len(lines)):
563
+ clean = _strip_luau_comment(lines[i]).strip()
564
+ if not clean:
565
+ continue
566
+ opens = len(open_pattern.findall(clean))
567
+ # `elseif` does not open a new `end` block
568
+ elseif_count = len(re.findall(r"\belseif\b", clean))
569
+ # `for ... do` or `while ... do` has both `for`/`while` and `do` on the same line for a single `end`
570
+ loop_do_count = len(re.findall(r"\b(?:for|while)\b.*\bdo\b", clean))
571
+ effective_opens = max(0, opens - elseif_count - loop_do_count)
572
+ closes = len(close_pattern.findall(clean))
573
+ if effective_opens > 0:
574
+ opened = True
575
+ depth += effective_opens - closes
576
+ if opened and depth <= 0:
577
+ return i + 1
578
+ return len(lines)
579
+
580
+
581
+ def _count_luau_params(params_str: str, is_colon_method: bool = False) -> int:
582
+ clean = params_str.strip()
583
+ if not clean:
584
+ return 1 if is_colon_method else 0
585
+ # Split on top-level commas (ignoring generic `<...>` or function types `(...) -> ...`)
586
+ depth = 0
587
+ count = 1
588
+ for ch in clean:
589
+ if ch in "(<{":
590
+ depth += 1
591
+ elif ch in ")>}":
592
+ depth = max(0, depth - 1)
593
+ elif ch == "," and depth == 0:
594
+ count += 1
595
+ return count + (1 if is_colon_method else 0)
596
+
597
+
598
+ def parse_luau(content: str, file_path: str) -> ParseResult:
599
+ """Parse a `.lua` or `.luau` source file in a single scoped pass and return a `ParseResult`."""
600
+ from codegraph.indexing.parser import ParseResult, _normalized_body_hash
601
+
602
+ source_hash = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()
603
+ module = normalize_module(file_path, "luau")
604
+ lines = content.splitlines()
605
+
606
+ symbols: list[Symbol] = []
607
+ imports: list[ImportRef] = []
608
+ calls: list[CallRef] = []
609
+ bindings: list[BindingRef] = []
610
+ exports: list[str] = []
611
+ seen_canonical: set[str] = set()
612
+
613
+ # Track local tables/classes and aliases for static resolution
614
+ known_tables: dict[str, int] = {} # table_name -> start_line
615
+ local_aliases: dict[str, str] = {} # var_name -> normalized chain (e.g. ReplicatedStorage.Packages)
616
+ active_func: tuple[str, str, int] | None = None # (qname, canonical_id, end_line)
617
+ in_multiline_comment = False
618
+
619
+ for idx, raw_line in enumerate(lines):
620
+ lineno = idx + 1
621
+
622
+ # Handle multiline comments `--[[ ... ]]`
623
+ stripped_raw = raw_line.strip()
624
+ if in_multiline_comment:
625
+ if "]]" in stripped_raw:
626
+ in_multiline_comment = False
627
+ continue
628
+ if stripped_raw.startswith("--[["):
629
+ if "]]" not in stripped_raw[4:]:
630
+ in_multiline_comment = True
631
+ continue
632
+
633
+ line = _strip_luau_comment(raw_line)
634
+ stripped = line.strip()
635
+ if not stripped:
636
+ continue
637
+
638
+ if active_func and lineno > active_func[2]:
639
+ active_func = None
640
+
641
+ current_scope = active_func[0] if active_func else ""
642
+ caller_canon = active_func[1] if active_func else module
643
+
644
+ # 1. Type declarations: `type State = ...` and `export type Config = ...`
645
+ m_type = _LUA_TYPE_DECL_RE.match(line)
646
+ if m_type:
647
+ is_exported = bool(m_type.group(1))
648
+ type_name = m_type.group(2)
649
+ type_rhs = m_type.group(3).strip()
650
+ end_ln = _estimate_block_end_braces(lines, idx) if "{" in type_rhs and "}" not in type_rhs else lineno
651
+ canon_id = build_canonical_id(module, "", type_name)
652
+ if canon_id not in seen_canonical:
653
+ seen_canonical.add(canon_id)
654
+ symbols.append(
655
+ Symbol(
656
+ id=canon_id,
657
+ canonical_id=canon_id,
658
+ name=type_name,
659
+ qualified_name=type_name,
660
+ kind="type",
661
+ language="luau",
662
+ module=module,
663
+ path=file_path,
664
+ file_path=file_path,
665
+ scope="",
666
+ signature=f"{'export ' if is_exported else ''}type {type_name} = {type_rhs}",
667
+ start_line=lineno,
668
+ end_line=end_ln,
669
+ content_hash=_normalized_body_hash(lines, lineno, end_ln),
670
+ visibility="public" if is_exported else "private",
671
+ )
672
+ )
673
+ if is_exported and type_name not in exports:
674
+ exports.append(type_name)
675
+ continue
676
+
677
+ # 2. Knit Service / Controller creation: `Knit.CreateService({ Name = "InventoryService" })`
678
+ m_knit_svc = _LUA_KNIT_CREATE_SERVICE_RE.search(line)
679
+ if m_knit_svc or "Knit.CreateService" in line:
680
+ var_nm = m_knit_svc.group(1) if m_knit_svc else None
681
+ if not var_nm:
682
+ m_var = re.match(r"^\s*(?:local\s+)?([A-Za-z_]\w*)\s*=\s*Knit\.CreateService", line)
683
+ var_nm = m_var.group(1) if m_var else None
684
+ block_end = _estimate_block_end_braces(lines, idx)
685
+ block_text = "\n".join(lines[idx:block_end])
686
+ m_name = re.search(r"""Name\s*=\s*(['"][^'"]+['"]|[A-Za-z_][\w.]*)""", block_text)
687
+ svc_raw_name = m_name.group(1).strip() if m_name else ""
688
+ is_static_name = (
689
+ (svc_raw_name.startswith('"') and svc_raw_name.endswith('"'))
690
+ or (svc_raw_name.startswith("'") and svc_raw_name.endswith("'"))
691
+ )
692
+ svc_name = svc_raw_name[1:-1] if is_static_name else (var_nm or "<DYNAMIC>")
693
+ table_ident = var_nm or (svc_name if is_static_name else "KnitService")
694
+ known_tables[table_ident] = lineno
695
+ canon_id = build_canonical_id(module, "", table_ident)
696
+ if canon_id not in seen_canonical:
697
+ seen_canonical.add(canon_id)
698
+ symbols.append(
699
+ Symbol(
700
+ id=canon_id,
701
+ canonical_id=canon_id,
702
+ name=table_ident,
703
+ qualified_name=table_ident,
704
+ kind="class",
705
+ language="luau",
706
+ module=module,
707
+ path=file_path,
708
+ file_path=file_path,
709
+ scope="",
710
+ signature=f"Knit.CreateService({svc_raw_name or table_ident})",
711
+ start_line=lineno,
712
+ end_line=block_end,
713
+ decorators=["Knit.CreateService"],
714
+ content_hash=_normalized_body_hash(lines, lineno, block_end),
715
+ )
716
+ )
717
+ bindings.append(
718
+ BindingRef(
719
+ target_name=svc_name if is_static_name else "<DYNAMIC>",
720
+ file_path=file_path,
721
+ line=lineno,
722
+ scope=table_ident,
723
+ expr_kind="ROBLOX_KNIT_CREATE_SERVICE",
724
+ source_expr=canon_id,
725
+ base_expr="Knit",
726
+ attr_name="CreateService",
727
+ is_conditional=not is_static_name,
728
+ )
729
+ )
730
+
731
+ m_knit_ctrl = _LUA_KNIT_CREATE_CONTROLLER_RE.search(line)
732
+ if m_knit_ctrl or "Knit.CreateController" in line:
733
+ var_nm = m_knit_ctrl.group(1) if m_knit_ctrl else None
734
+ if not var_nm:
735
+ m_var = re.match(r"^\s*(?:local\s+)?([A-Za-z_]\w*)\s*=\s*Knit\.CreateController", line)
736
+ var_nm = m_var.group(1) if m_var else None
737
+ block_end = _estimate_block_end_braces(lines, idx)
738
+ block_text = "\n".join(lines[idx:block_end])
739
+ m_name = re.search(r"""Name\s*=\s*(['"][^'"]+['"]|[A-Za-z_][\w.]*)""", block_text)
740
+ ctrl_raw_name = m_name.group(1).strip() if m_name else ""
741
+ is_static_name = (
742
+ (ctrl_raw_name.startswith('"') and ctrl_raw_name.endswith('"'))
743
+ or (ctrl_raw_name.startswith("'") and ctrl_raw_name.endswith("'"))
744
+ )
745
+ ctrl_name = ctrl_raw_name[1:-1] if is_static_name else (var_nm or "<DYNAMIC>")
746
+ table_ident = var_nm or (ctrl_name if is_static_name else "KnitController")
747
+ known_tables[table_ident] = lineno
748
+ canon_id = build_canonical_id(module, "", table_ident)
749
+ if canon_id not in seen_canonical:
750
+ seen_canonical.add(canon_id)
751
+ symbols.append(
752
+ Symbol(
753
+ id=canon_id,
754
+ canonical_id=canon_id,
755
+ name=table_ident,
756
+ qualified_name=table_ident,
757
+ kind="class",
758
+ language="luau",
759
+ module=module,
760
+ path=file_path,
761
+ file_path=file_path,
762
+ scope="",
763
+ signature=f"Knit.CreateController({ctrl_raw_name or table_ident})",
764
+ start_line=lineno,
765
+ end_line=block_end,
766
+ decorators=["Knit.CreateController"],
767
+ content_hash=_normalized_body_hash(lines, lineno, block_end),
768
+ )
769
+ )
770
+
771
+ # 3. Knit.GetService(...) / Knit.GetController(...)
772
+ m_get_svc = _LUA_KNIT_GET_SERVICE_RE.search(line)
773
+ if m_get_svc:
774
+ local_var = m_get_svc.group(1) or ""
775
+ arg_raw = m_get_svc.group(2).strip()
776
+ is_static = (
777
+ (arg_raw.startswith('"') and arg_raw.endswith('"'))
778
+ or (arg_raw.startswith("'") and arg_raw.endswith("'"))
779
+ )
780
+ svc_target = arg_raw[1:-1] if is_static else "<DYNAMIC>"
781
+ if local_var and is_static:
782
+ local_aliases[local_var] = f"KnitService:{svc_target}"
783
+ bindings.append(
784
+ BindingRef(
785
+ target_name=svc_target,
786
+ file_path=file_path,
787
+ line=lineno,
788
+ scope=current_scope,
789
+ expr_kind="ROBLOX_KNIT_GET_SERVICE",
790
+ source_expr=arg_raw,
791
+ base_expr=local_var or caller_canon,
792
+ attr_name="GetService",
793
+ is_conditional=not is_static,
794
+ )
795
+ )
796
+
797
+ # 4. Module / class table declaration: `local Service = {}` or `Service = {}`
798
+ m_tbl = _LUA_TABLE_DECL_RE.match(line)
799
+ if m_tbl and not active_func:
800
+ tbl_name = m_tbl.group(1)
801
+ tbl_type = (m_tbl.group(2) or "").strip() or None
802
+ if tbl_name not in _LUAU_KEYWORDS_AND_BUILTINS:
803
+ known_tables[tbl_name] = lineno
804
+ canon_id = build_canonical_id(module, "", tbl_name)
805
+ if canon_id not in seen_canonical:
806
+ seen_canonical.add(canon_id)
807
+ symbols.append(
808
+ Symbol(
809
+ id=canon_id,
810
+ canonical_id=canon_id,
811
+ name=tbl_name,
812
+ qualified_name=tbl_name,
813
+ kind="class",
814
+ language="luau",
815
+ module=module,
816
+ path=file_path,
817
+ file_path=file_path,
818
+ scope="",
819
+ signature=f"table {tbl_name}" + (f": {tbl_type}" if tbl_type else ""),
820
+ start_line=lineno,
821
+ end_line=len(lines),
822
+ return_type=tbl_type,
823
+ content_hash=_normalized_body_hash(lines, lineno, min(len(lines), lineno + 25)),
824
+ )
825
+ )
826
+
827
+ # 5. Method declaration: `function Service:Init(...)` or `function Service.Method(...)`
828
+ m_meth = _LUA_METHOD_DECL_RE.match(line)
829
+ if m_meth:
830
+ tbl_name = m_meth.group(1)
831
+ sep = m_meth.group(2)
832
+ meth_name = m_meth.group(3)
833
+ params_str = m_meth.group(4)
834
+ ret_type = (m_meth.group(5) or "").strip() or None
835
+ end_ln = _estimate_luau_block_end(lines, idx)
836
+ qname = f"{tbl_name}.{meth_name}"
837
+ canon_id = build_canonical_id(module, tbl_name, meth_name)
838
+ parent_id: str | None = build_canonical_id(module, "", tbl_name)
839
+ param_cnt = _count_luau_params(params_str, is_colon_method=(sep == ":"))
840
+ sig = f"function {tbl_name}{sep}{meth_name}({params_str})" + (f": {ret_type}" if ret_type else "")
841
+ if canon_id not in seen_canonical:
842
+ seen_canonical.add(canon_id)
843
+ symbols.append(
844
+ Symbol(
845
+ id=canon_id,
846
+ canonical_id=canon_id,
847
+ name=meth_name,
848
+ qualified_name=qname,
849
+ kind="method",
850
+ language="luau",
851
+ module=module,
852
+ path=file_path,
853
+ file_path=file_path,
854
+ scope=tbl_name,
855
+ signature=sig,
856
+ start_line=lineno,
857
+ end_line=end_ln,
858
+ content_hash=_normalized_body_hash(lines, lineno, end_ln),
859
+ parent_symbol_id=parent_id,
860
+ return_type=ret_type,
861
+ parameter_count=param_cnt,
862
+ )
863
+ )
864
+ active_func = (qname, canon_id, end_ln)
865
+ continue
866
+
867
+ # 6. Assignment method / function: `Service.Method = function(...)`
868
+ m_assign_fn = _LUA_ASSIGN_FUNC_RE.match(line)
869
+ if m_assign_fn and not m_assign_fn.group(1).endswith((".OnServerInvoke", ".OnClientInvoke")):
870
+ lhs = m_assign_fn.group(1)
871
+ params_str = m_assign_fn.group(2)
872
+ ret_type = (m_assign_fn.group(3) or "").strip() or None
873
+ end_ln = _estimate_luau_block_end(lines, idx)
874
+ if "." in lhs:
875
+ tbl_name, meth_name = lhs.rsplit(".", 1)
876
+ qname = f"{tbl_name}.{meth_name}"
877
+ canon_id = build_canonical_id(module, tbl_name, meth_name)
878
+ parent_id = build_canonical_id(module, "", tbl_name)
879
+ kind = "method"
880
+ scope_str = tbl_name
881
+ else:
882
+ meth_name = lhs
883
+ qname = lhs
884
+ canon_id = build_canonical_id(module, "", lhs)
885
+ parent_id = None
886
+ kind = "function"
887
+ scope_str = ""
888
+ param_cnt = _count_luau_params(params_str, is_colon_method=False)
889
+ sig = f"{lhs} = function({params_str})" + (f": {ret_type}" if ret_type else "")
890
+ if canon_id not in seen_canonical:
891
+ seen_canonical.add(canon_id)
892
+ symbols.append(
893
+ Symbol(
894
+ id=canon_id,
895
+ canonical_id=canon_id,
896
+ name=meth_name,
897
+ qualified_name=qname,
898
+ kind=kind,
899
+ language="luau",
900
+ module=module,
901
+ path=file_path,
902
+ file_path=file_path,
903
+ scope=scope_str,
904
+ signature=sig,
905
+ start_line=lineno,
906
+ end_line=end_ln,
907
+ content_hash=_normalized_body_hash(lines, lineno, end_ln),
908
+ parent_symbol_id=parent_id,
909
+ return_type=ret_type,
910
+ parameter_count=param_cnt,
911
+ )
912
+ )
913
+ active_func = (qname, canon_id, end_ln)
914
+ continue
915
+
916
+ # 7. Local function declaration: `local function foo(x: number): string` or `local foo = function(...)`
917
+ m_loc_fn = _LUA_LOCAL_FUNC_RE.match(line) or _LUA_LOCAL_ASSIGN_FUNC_RE.match(line)
918
+ if m_loc_fn:
919
+ fn_name = m_loc_fn.group(1)
920
+ params_str = m_loc_fn.group(2)
921
+ ret_type = (m_loc_fn.group(3) or "").strip() or None
922
+ end_ln = _estimate_luau_block_end(lines, idx)
923
+ canon_id = build_canonical_id(module, "", fn_name)
924
+ param_cnt = _count_luau_params(params_str, is_colon_method=False)
925
+ sig = f"local function {fn_name}({params_str})" + (f": {ret_type}" if ret_type else "")
926
+ if canon_id not in seen_canonical:
927
+ seen_canonical.add(canon_id)
928
+ symbols.append(
929
+ Symbol(
930
+ id=canon_id,
931
+ canonical_id=canon_id,
932
+ name=fn_name,
933
+ qualified_name=fn_name,
934
+ kind="function",
935
+ language="luau",
936
+ module=module,
937
+ path=file_path,
938
+ file_path=file_path,
939
+ scope="",
940
+ signature=sig,
941
+ start_line=lineno,
942
+ end_line=end_ln,
943
+ content_hash=_normalized_body_hash(lines, lineno, end_ln),
944
+ visibility="private",
945
+ return_type=ret_type,
946
+ parameter_count=param_cnt,
947
+ )
948
+ )
949
+ active_func = (fn_name, canon_id, end_ln)
950
+ continue
951
+
952
+ # 8. Global function declaration: `function globalHelper(...)`
953
+ m_glob_fn = _LUA_GLOBAL_FUNC_RE.match(line)
954
+ if m_glob_fn:
955
+ fn_name = m_glob_fn.group(1)
956
+ params_str = m_glob_fn.group(2)
957
+ ret_type = (m_glob_fn.group(3) or "").strip() or None
958
+ end_ln = _estimate_luau_block_end(lines, idx)
959
+ canon_id = build_canonical_id(module, "", fn_name)
960
+ param_cnt = _count_luau_params(params_str, is_colon_method=False)
961
+ sig = f"function {fn_name}({params_str})" + (f": {ret_type}" if ret_type else "")
962
+ if canon_id not in seen_canonical:
963
+ seen_canonical.add(canon_id)
964
+ symbols.append(
965
+ Symbol(
966
+ id=canon_id,
967
+ canonical_id=canon_id,
968
+ name=fn_name,
969
+ qualified_name=fn_name,
970
+ kind="function",
971
+ language="luau",
972
+ module=module,
973
+ path=file_path,
974
+ file_path=file_path,
975
+ scope="",
976
+ signature=sig,
977
+ start_line=lineno,
978
+ end_line=end_ln,
979
+ content_hash=_normalized_body_hash(lines, lineno, end_ln),
980
+ visibility="public",
981
+ return_type=ret_type,
982
+ parameter_count=param_cnt,
983
+ )
984
+ )
985
+ active_func = (fn_name, canon_id, end_ln)
986
+ continue
987
+
988
+ # 9. `game:GetService("...")` binding
989
+ m_svc = _LUA_GET_SERVICE_RE.match(line)
990
+ if m_svc:
991
+ var_name = m_svc.group(1)
992
+ service_name = m_svc.group(2)
993
+ local_aliases[var_name] = service_name
994
+ bindings.append(
995
+ BindingRef(
996
+ target_name=var_name,
997
+ file_path=file_path,
998
+ line=lineno,
999
+ scope=current_scope,
1000
+ expr_kind="ROBLOX_SERVICE_BINDING",
1001
+ source_expr=service_name,
1002
+ base_expr="game",
1003
+ attr_name="GetService",
1004
+ )
1005
+ )
1006
+ continue
1007
+
1008
+ # 10. DataStore / ProfileService persistence configuration
1009
+ m_ds = _LUA_GET_DATASTORE_RE.match(line)
1010
+ if m_ds:
1011
+ store_var = m_ds.group(1)
1012
+ svc_expr = m_ds.group(2).strip()
1013
+ ds_method = m_ds.group(3)
1014
+ arg_raw = m_ds.group(4).split(",")[0].strip()
1015
+ norm_svc, _ = _normalize_roblox_chain(svc_expr, local_aliases)
1016
+ if norm_svc == "DataStoreService" or "DataStoreService" in svc_expr:
1017
+ is_static = (
1018
+ (arg_raw.startswith('"') and arg_raw.endswith('"'))
1019
+ or (arg_raw.startswith("'") and arg_raw.endswith("'"))
1020
+ )
1021
+ store_name = arg_raw[1:-1] if is_static else "<DYNAMIC>"
1022
+ local_aliases[store_var] = f"persistence.roblox.datastore.{store_name}"
1023
+ bindings.append(
1024
+ BindingRef(
1025
+ target_name=f"persistence.roblox.datastore.{store_name}" if is_static else "persistence.roblox.datastore.<DYNAMIC>",
1026
+ file_path=file_path,
1027
+ line=lineno,
1028
+ scope=current_scope,
1029
+ expr_kind="ROBLOX_CONFIGURE_PERSISTENCE",
1030
+ source_expr=caller_canon,
1031
+ base_expr=store_var,
1032
+ attr_name=ds_method,
1033
+ is_conditional=not is_static,
1034
+ )
1035
+ )
1036
+ continue
1037
+
1038
+ m_ps = _LUA_PROFILESERVICE_STORE_RE.match(line)
1039
+ if m_ps:
1040
+ store_var = m_ps.group(1)
1041
+ ps_recv = m_ps.group(2)
1042
+ arg_raw = m_ps.group(3).strip()
1043
+ if ps_recv == "ProfileService" or local_aliases.get(ps_recv, "").endswith("ProfileService"):
1044
+ is_static = (
1045
+ (arg_raw.startswith('"') and arg_raw.endswith('"'))
1046
+ or (arg_raw.startswith("'") and arg_raw.endswith("'"))
1047
+ )
1048
+ store_name = arg_raw[1:-1] if is_static else "<DYNAMIC>"
1049
+ local_aliases[store_var] = f"persistence.roblox.datastore.{store_name}"
1050
+ bindings.append(
1051
+ BindingRef(
1052
+ target_name=f"persistence.roblox.datastore.{store_name}" if is_static else "persistence.roblox.datastore.<DYNAMIC>",
1053
+ file_path=file_path,
1054
+ line=lineno,
1055
+ scope=current_scope,
1056
+ expr_kind="ROBLOX_CONFIGURE_PERSISTENCE",
1057
+ source_expr=caller_canon,
1058
+ base_expr=store_var,
1059
+ attr_name="GetProfileStore",
1060
+ is_conditional=not is_static,
1061
+ )
1062
+ )
1063
+ continue
1064
+
1065
+ # 11. `require(...)` statements
1066
+ m_req = _LUA_REQUIRE_ASSIGN_RE.match(line)
1067
+ if m_req:
1068
+ local_var = m_req.group(1)
1069
+ req_expr = m_req.group(2).strip()
1070
+ norm_req, is_dyn = _normalize_roblox_chain(req_expr, local_aliases)
1071
+ # Also check if string literal require("...")
1072
+ if (req_expr.startswith('"') and req_expr.endswith('"')) or (
1073
+ req_expr.startswith("'") and req_expr.endswith("'")
1074
+ ):
1075
+ norm_req = req_expr[1:-1]
1076
+ is_dyn = False
1077
+ local_aliases[local_var] = norm_req
1078
+ imports.append(
1079
+ ImportRef(
1080
+ module=norm_req if not is_dyn else req_expr,
1081
+ imported_module=norm_req if not is_dyn else req_expr,
1082
+ name=None,
1083
+ alias=local_var,
1084
+ local_name=local_var,
1085
+ import_type="module",
1086
+ line=lineno,
1087
+ source_file=file_path,
1088
+ source_module=module,
1089
+ )
1090
+ )
1091
+ bindings.append(
1092
+ BindingRef(
1093
+ target_name=local_var,
1094
+ file_path=file_path,
1095
+ line=lineno,
1096
+ scope=current_scope,
1097
+ expr_kind="ROBLOX_REQUIRE",
1098
+ source_expr=norm_req if not is_dyn else "<DYNAMIC>",
1099
+ base_expr=req_expr,
1100
+ attr_name=caller_canon,
1101
+ is_conditional=is_dyn,
1102
+ )
1103
+ )
1104
+ continue
1105
+
1106
+ # Bare `require(...)` (e.g., `return require(...)` or inline `require(...)`)
1107
+ for m_bare_req in _LUA_BARE_REQUIRE_RE.finditer(line):
1108
+ req_expr = m_bare_req.group(1).strip()
1109
+ norm_req, is_dyn = _normalize_roblox_chain(req_expr, local_aliases)
1110
+ if (req_expr.startswith('"') and req_expr.endswith('"')) or (
1111
+ req_expr.startswith("'") and req_expr.endswith("'")
1112
+ ):
1113
+ norm_req = req_expr[1:-1]
1114
+ is_dyn = False
1115
+ imports.append(
1116
+ ImportRef(
1117
+ module=norm_req if not is_dyn else req_expr,
1118
+ imported_module=norm_req if not is_dyn else req_expr,
1119
+ name=None,
1120
+ alias=None,
1121
+ local_name=norm_req.split(".")[-1] if norm_req else "require",
1122
+ import_type="module",
1123
+ line=lineno,
1124
+ source_file=file_path,
1125
+ source_module=module,
1126
+ )
1127
+ )
1128
+ bindings.append(
1129
+ BindingRef(
1130
+ target_name="require",
1131
+ file_path=file_path,
1132
+ line=lineno,
1133
+ scope=current_scope,
1134
+ expr_kind="ROBLOX_REQUIRE",
1135
+ source_expr=norm_req if not is_dyn else "<DYNAMIC>",
1136
+ base_expr=req_expr,
1137
+ attr_name=caller_canon,
1138
+ is_conditional=is_dyn,
1139
+ )
1140
+ )
1141
+
1142
+ # 12. Return statements (`return table.freeze(Module)` / `return Service`)
1143
+ m_freeze = _LUA_RETURN_FREEZE_RE.match(line)
1144
+ if m_freeze:
1145
+ exp_ident = m_freeze.group(1)
1146
+ if exp_ident not in exports:
1147
+ exports.append(exp_ident)
1148
+ bindings.append(
1149
+ BindingRef(
1150
+ target_name=exp_ident,
1151
+ file_path=file_path,
1152
+ line=lineno,
1153
+ scope=current_scope,
1154
+ expr_kind="ROBLOX_MODULE_EXPORT",
1155
+ source_expr="table.freeze",
1156
+ base_expr=module,
1157
+ )
1158
+ )
1159
+ continue
1160
+
1161
+ m_ret = _LUA_RETURN_IDENT_RE.match(line)
1162
+ if m_ret and not active_func:
1163
+ exp_ident = m_ret.group(1)
1164
+ if exp_ident not in _LUAU_KEYWORDS_AND_BUILTINS and exp_ident not in exports:
1165
+ exports.append(exp_ident)
1166
+ bindings.append(
1167
+ BindingRef(
1168
+ target_name=exp_ident,
1169
+ file_path=file_path,
1170
+ line=lineno,
1171
+ scope="",
1172
+ expr_kind="ROBLOX_MODULE_EXPORT",
1173
+ source_expr=exp_ident,
1174
+ base_expr=module,
1175
+ )
1176
+ )
1177
+ continue
1178
+
1179
+ # 13. Remote networking: FireServer / InvokeServer / OnServerEvent:Connect / OnServerInvoke
1180
+ m_rem_fire = _LUA_REMOTE_FIRE_RE.search(line)
1181
+ if m_rem_fire:
1182
+ rem_expr = m_rem_fire.group(1).strip()
1183
+ fire_method = m_rem_fire.group(2)
1184
+ norm_rem, is_dyn = _normalize_roblox_chain(rem_expr, local_aliases)
1185
+ bindings.append(
1186
+ BindingRef(
1187
+ target_name=norm_rem if not is_dyn else "<DYNAMIC>",
1188
+ file_path=file_path,
1189
+ line=lineno,
1190
+ scope=current_scope,
1191
+ expr_kind="ROBLOX_REMOTE_DISPATCH",
1192
+ source_expr=caller_canon,
1193
+ base_expr=rem_expr,
1194
+ attr_name=fire_method,
1195
+ is_conditional=is_dyn,
1196
+ )
1197
+ )
1198
+
1199
+ m_rem_conn = _LUA_REMOTE_CONNECT_RE.search(line)
1200
+ if m_rem_conn:
1201
+ rem_expr = m_rem_conn.group(1).strip()
1202
+ event_prop = m_rem_conn.group(2)
1203
+ handler_arg = m_rem_conn.group(3).strip()
1204
+ norm_rem, is_dyn = _normalize_roblox_chain(rem_expr, local_aliases)
1205
+ handler_sym = caller_canon
1206
+ if re.match(r"^[A-Za-z_][\w.]*$", handler_arg) and not handler_arg.startswith("function"):
1207
+ handler_sym = f"{module}.{handler_arg}"
1208
+ elif handler_arg.startswith("function") and active_func is None:
1209
+ end_ln = _estimate_luau_block_end(lines, idx)
1210
+ active_func = (current_scope or "OnServerEvent", caller_canon, end_ln)
1211
+ bindings.append(
1212
+ BindingRef(
1213
+ target_name=norm_rem if not is_dyn else "<DYNAMIC>",
1214
+ file_path=file_path,
1215
+ line=lineno,
1216
+ scope=current_scope,
1217
+ expr_kind="ROBLOX_REMOTE_HANDLER",
1218
+ source_expr=handler_sym,
1219
+ base_expr=rem_expr,
1220
+ attr_name=event_prop,
1221
+ is_conditional=is_dyn,
1222
+ )
1223
+ )
1224
+
1225
+ m_rem_inv = _LUA_REMOTE_INVOKE_ASSIGN_RE.match(line)
1226
+ if m_rem_inv:
1227
+ rem_expr = m_rem_inv.group(1).strip()
1228
+ inv_prop = m_rem_inv.group(2)
1229
+ rhs_expr = m_rem_inv.group(3).strip()
1230
+ norm_rem, is_dyn = _normalize_roblox_chain(rem_expr, local_aliases)
1231
+ handler_sym = caller_canon
1232
+ if re.match(r"^[A-Za-z_][\w.]*$", rhs_expr) and not rhs_expr.startswith("function"):
1233
+ handler_sym = f"{module}.{rhs_expr}"
1234
+ elif rhs_expr.startswith("function") and active_func is None:
1235
+ end_ln = _estimate_luau_block_end(lines, idx)
1236
+ active_func = (current_scope or "OnServerInvoke", caller_canon, end_ln)
1237
+ bindings.append(
1238
+ BindingRef(
1239
+ target_name=norm_rem if not is_dyn else "<DYNAMIC>",
1240
+ file_path=file_path,
1241
+ line=lineno,
1242
+ scope=current_scope,
1243
+ expr_kind="ROBLOX_REMOTE_HANDLER",
1244
+ source_expr=handler_sym,
1245
+ base_expr=rem_expr,
1246
+ attr_name=inv_prop,
1247
+ is_conditional=is_dyn,
1248
+ )
1249
+ )
1250
+
1251
+ # 14. DataStore / ProfileService read & write operations
1252
+ for m_ds_op in _LUA_DATASTORE_OP_RE.finditer(line):
1253
+ recv_var = m_ds_op.group(1)
1254
+ op_name = m_ds_op.group(2)
1255
+ mapped_store = local_aliases.get(recv_var, "")
1256
+ if mapped_store.startswith("persistence.roblox.datastore."):
1257
+ op_kind = (
1258
+ "ROBLOX_READ_PERSISTENCE"
1259
+ if op_name in ("GetAsync", "LoadProfileAsync")
1260
+ else "ROBLOX_WRITE_PERSISTENCE"
1261
+ )
1262
+ is_dyn_store = mapped_store.endswith("<DYNAMIC>")
1263
+ bindings.append(
1264
+ BindingRef(
1265
+ target_name=mapped_store,
1266
+ file_path=file_path,
1267
+ line=lineno,
1268
+ scope=current_scope,
1269
+ expr_kind=op_kind,
1270
+ source_expr=caller_canon,
1271
+ base_expr=recv_var,
1272
+ attr_name=op_name,
1273
+ is_conditional=is_dyn_store,
1274
+ )
1275
+ )
1276
+
1277
+ # 15. Local alias chains: `local Packages = ReplicatedStorage.Packages`
1278
+ # or `local Remotes = ReplicatedStorage:WaitForChild("Remotes")`
1279
+ # or `local x: string = "hello"`
1280
+ m_typed = _LUA_TYPED_VAR_RE.match(line)
1281
+ if m_typed and not active_func:
1282
+ v_name = m_typed.group(1)
1283
+ v_type = m_typed.group(2).strip()
1284
+ canon_id = build_canonical_id(module, "", v_name)
1285
+ if canon_id not in seen_canonical and v_name not in known_tables:
1286
+ seen_canonical.add(canon_id)
1287
+ symbols.append(
1288
+ Symbol(
1289
+ id=canon_id,
1290
+ canonical_id=canon_id,
1291
+ name=v_name,
1292
+ qualified_name=v_name,
1293
+ kind="variable",
1294
+ language="luau",
1295
+ module=module,
1296
+ path=file_path,
1297
+ file_path=file_path,
1298
+ scope="",
1299
+ signature=f"local {v_name}: {v_type}",
1300
+ start_line=lineno,
1301
+ end_line=lineno,
1302
+ return_type=v_type,
1303
+ visibility="private",
1304
+ content_hash=_normalized_body_hash(lines, lineno, lineno),
1305
+ )
1306
+ )
1307
+
1308
+ m_alias = _LUA_ALIAS_CHAIN_RE.match(line)
1309
+ if m_alias:
1310
+ lhs_var = m_alias.group(1)
1311
+ rhs_val = m_alias.group(2).strip()
1312
+ if not rhs_val.startswith(("function", "{", "require(")):
1313
+ norm_chain, is_dyn = _normalize_roblox_chain(rhs_val, local_aliases)
1314
+ if norm_chain and not is_dyn:
1315
+ head_seg = norm_chain.split(".")[0]
1316
+ if head_seg in _ROBLOX_DATAMODEL_SERVICES or head_seg == "script":
1317
+ local_aliases[lhs_var] = norm_chain
1318
+ elif is_dyn and any(s in rhs_val for s in ("WaitForChild", "FindFirstChild", "ReplicatedStorage", "ServerScriptService")):
1319
+ local_aliases[lhs_var] = "<DYNAMIC>"
1320
+
1321
+ # 16. General function / method call sites
1322
+ for cm in _LUA_CALL_SITE_RE.finditer(line):
1323
+ recv = cm.group(1)
1324
+ sep = cm.group(2)
1325
+ callee_nm = cm.group(3)
1326
+ if callee_nm in _LUAU_KEYWORDS_AND_BUILTINS:
1327
+ continue
1328
+ if callee_nm in ("FireServer", "InvokeServer", "FireClient", "FireAllClients", "Connect", "WaitForChild", "FindFirstChild", "GetService"):
1329
+ continue
1330
+ if recv in _LUAU_KEYWORDS_AND_BUILTINS:
1331
+ continue
1332
+ resolved_recv = recv
1333
+ if recv == "self" and current_scope and "." in current_scope:
1334
+ resolved_recv = current_scope.split(".")[0]
1335
+ elif recv and recv in local_aliases:
1336
+ alias_val = local_aliases[recv]
1337
+ if alias_val.startswith("KnitService:"):
1338
+ resolved_recv = alias_val.split(":", 1)[1]
1339
+ elif not alias_val.startswith(("persistence.", "<DYNAMIC")):
1340
+ resolved_recv = alias_val.split(".")[-1]
1341
+ q_callee = f"{resolved_recv}.{callee_nm}" if resolved_recv else callee_nm
1342
+ calls.append(
1343
+ CallRef(
1344
+ callee=callee_nm,
1345
+ qualified_callee=q_callee,
1346
+ line=lineno,
1347
+ end_line=lineno,
1348
+ source_file=file_path,
1349
+ confidence="LOW",
1350
+ caller_symbol=current_scope or None,
1351
+ caller_canonical_id=caller_canon,
1352
+ receiver=resolved_recv,
1353
+ )
1354
+ )
1355
+
1356
+ symbols.sort(key=lambda s: (s.start_line, s.canonical_id))
1357
+ return ParseResult(
1358
+ path=file_path,
1359
+ language="luau",
1360
+ source_hash=source_hash,
1361
+ symbols=tuple(symbols),
1362
+ imports=tuple(imports),
1363
+ calls=tuple(calls),
1364
+ bindings=tuple(bindings),
1365
+ exports=tuple(exports),
1366
+ parse_failed=False,
1367
+ )
1368
+
1369
+
1370
+ def _estimate_block_end_braces(lines: list[str], start_idx: int) -> int:
1371
+ depth = 0
1372
+ opened = False
1373
+ for i in range(start_idx, len(lines)):
1374
+ clean = _strip_luau_comment(lines[i])
1375
+ opens = clean.count("{")
1376
+ closes = clean.count("}")
1377
+ if opens > 0:
1378
+ opened = True
1379
+ depth += opens - closes
1380
+ if opened and depth <= 0:
1381
+ return i + 1
1382
+ return len(lines)
1383
+
1384
+
1385
+ # ---------------------------------------------------------------------------
1386
+ # Phase R7: Flamework TypeScript Support (@Service, @Controller, Dependency<T>)
1387
+ # ---------------------------------------------------------------------------
1388
+
1389
+ _FLAMEWORK_DECORATOR_RE = re.compile(r"@(Service|Controller)\s*\(")
1390
+ _FLAMEWORK_CLASS_RE = re.compile(r"\bexport\s+class\s+([A-Za-z_$][\w$]*)")
1391
+ _FLAMEWORK_DEPENDENCY_RE = re.compile(
1392
+ r"(?:(?:const|let|var|readonly|private|protected|public)\s+([A-Za-z_$][\w$]*)\s*=\s*)?Dependency\s*<\s*([A-Za-z_$][\w$]*)\s*>\s*\(\s*\)"
1393
+ )
1394
+
1395
+
1396
+ def extract_flamework_bindings(content: str, file_path: str, module: str) -> list[BindingRef]:
1397
+ """Extract Flamework `@Service()`, `@Controller()`, and `Dependency<T>()` bindings from TypeScript files."""
1398
+ if "@flamework" not in content and "Dependency<" not in content and "@Service" not in content and "@Controller" not in content:
1399
+ return []
1400
+
1401
+ bindings: list[BindingRef] = []
1402
+ lines = content.splitlines()
1403
+ pending_decorator: tuple[str, int] | None = None
1404
+ active_class: str | None = None
1405
+
1406
+ for idx, line in enumerate(lines):
1407
+ lineno = idx + 1
1408
+ m_dec = _FLAMEWORK_DECORATOR_RE.search(line)
1409
+ if m_dec:
1410
+ pending_decorator = (m_dec.group(1), lineno)
1411
+
1412
+ m_cls = _FLAMEWORK_CLASS_RE.search(line)
1413
+ if m_cls:
1414
+ cls_name = m_cls.group(1)
1415
+ active_class = cls_name
1416
+ if pending_decorator:
1417
+ dec_kind, dec_line = pending_decorator
1418
+ bindings.append(
1419
+ BindingRef(
1420
+ target_name=cls_name,
1421
+ file_path=file_path,
1422
+ line=dec_line,
1423
+ scope=cls_name,
1424
+ expr_kind="ROBLOX_FLAMEWORK_PROVIDER",
1425
+ source_expr=f"{module}.{cls_name}",
1426
+ base_expr="Flamework",
1427
+ attr_name=dec_kind,
1428
+ )
1429
+ )
1430
+ pending_decorator = None
1431
+
1432
+ m_dep = _FLAMEWORK_DEPENDENCY_RE.search(line)
1433
+ if m_dep:
1434
+ dep_type = m_dep.group(2)
1435
+ caller_scope = f"{module}.{active_class}" if active_class else module
1436
+ bindings.append(
1437
+ BindingRef(
1438
+ target_name=dep_type,
1439
+ file_path=file_path,
1440
+ line=lineno,
1441
+ scope=active_class or "",
1442
+ expr_kind="ROBLOX_FLAMEWORK_DEPENDENCY",
1443
+ source_expr=caller_scope,
1444
+ base_expr="Flamework",
1445
+ attr_name="Dependency",
1446
+ )
1447
+ )
1448
+
1449
+ return bindings
1450
+
1451
+
1452
+ # ---------------------------------------------------------------------------
1453
+ # Global Resolution Pass for Roblox Intelligence (Phases R2–R8)
1454
+ # ---------------------------------------------------------------------------
1455
+
1456
+
1457
+ def resolve_roblox_intelligence(
1458
+ *,
1459
+ repository: Path | None,
1460
+ symbols: list[Symbol],
1461
+ bindings: list[BindingRef],
1462
+ known_files: set[str],
1463
+ file_hashes: dict[str, str],
1464
+ indexed_commit: str | None,
1465
+ add_ref: Any,
1466
+ add_edge: Any,
1467
+ resolved_imports: dict[tuple[str, int, str], tuple[str | None, str | None]],
1468
+ ) -> None:
1469
+ """Resolve Rojo virtual paths, Luau module requires, RemoteEvent/RemoteFunction networking,
1470
+ Knit/Flamework services, and DataStore/ProfileService persistence into verified graph edges.
1471
+ """
1472
+ rojo = RojoProject.load(repository)
1473
+
1474
+ # Index Knit & Flamework provided services: service_name -> provider_canonical_id
1475
+ service_providers: dict[str, str] = {}
1476
+ for b in bindings:
1477
+ if b.expr_kind == "ROBLOX_KNIT_CREATE_SERVICE" and b.target_name != "<DYNAMIC>":
1478
+ service_providers[b.target_name] = b.source_expr
1479
+ elif b.expr_kind == "ROBLOX_FLAMEWORK_PROVIDER":
1480
+ service_providers[b.target_name] = b.source_expr
1481
+
1482
+ # Also index symbols by short name and module for Knit/Flamework fallback
1483
+ short_to_symbols: dict[str, list[Symbol]] = {}
1484
+ for s in symbols:
1485
+ short_to_symbols.setdefault(s.name, []).append(s)
1486
+
1487
+ for b in bindings:
1488
+ f_hash = file_hashes.get(b.file_path, "")
1489
+
1490
+ # 1. Phase R2 & R3: ROBLOX_REQUIRE -> REQUIRES_MODULE
1491
+ if b.expr_kind == "ROBLOX_REQUIRE":
1492
+ caller_id = b.attr_name or normalize_module(b.file_path)
1493
+ req_target = b.source_expr
1494
+ raw_expr = b.base_expr
1495
+
1496
+ if b.is_conditional or req_target == "<DYNAMIC>":
1497
+ # Dynamic require -> UNKNOWN
1498
+ ev_str = f"Dynamic require({raw_expr}) in {b.file_path}:{b.line}"
1499
+ add_edge(
1500
+ GraphEdge(
1501
+ source=caller_id,
1502
+ target=f"unresolved.module.{raw_expr}",
1503
+ relationship="REQUIRES_MODULE",
1504
+ confidence="UNKNOWN",
1505
+ file=b.file_path,
1506
+ start_line=b.line,
1507
+ end_line=b.line,
1508
+ evidence=ev_str,
1509
+ evidence_class=RelationshipEvidenceClass.UNKNOWN.value,
1510
+ reason="dynamic_luau_require",
1511
+ )
1512
+ )
1513
+ continue
1514
+
1515
+ target_file: str | None = None
1516
+ ev_cls = RelationshipEvidenceClass.AST_VERIFIED.value
1517
+
1518
+ if req_target.startswith("script"):
1519
+ target_file, ev_cls = resolve_luau_relative_script_path(
1520
+ req_target, b.file_path, known_files, rojo=rojo
1521
+ )
1522
+ elif any(req_target.startswith(svc) for svc in _ROBLOX_DATAMODEL_SERVICES):
1523
+ target_file, ev_cls = rojo.resolve_virtual_to_file(req_target, known_files)
1524
+
1525
+ if target_file:
1526
+ target_mod = normalize_module(target_file)
1527
+ resolved_imports[(b.file_path, b.line, b.target_name)] = (target_file, target_mod)
1528
+ ev_str = f"require({raw_expr}) -> {target_file} ({ev_cls})"
1529
+ add_ref(
1530
+ Reference(
1531
+ source_symbol_id=caller_id,
1532
+ target_symbol_id=target_mod,
1533
+ relationship="REQUIRES_MODULE",
1534
+ confidence="HIGH",
1535
+ path=b.file_path,
1536
+ start_line=b.line,
1537
+ end_line=b.line,
1538
+ evidence=ev_str,
1539
+ source_hash=f_hash,
1540
+ indexed_commit=indexed_commit,
1541
+ )
1542
+ )
1543
+ add_edge(
1544
+ GraphEdge(
1545
+ source=caller_id,
1546
+ target=target_mod,
1547
+ relationship="REQUIRES_MODULE",
1548
+ confidence="HIGH",
1549
+ file=b.file_path,
1550
+ start_line=b.line,
1551
+ end_line=b.line,
1552
+ evidence=ev_str,
1553
+ evidence_class=ev_cls,
1554
+ reason=f"luau_require:{target_file}",
1555
+ )
1556
+ )
1557
+ else:
1558
+ # Unresolved require (e.g. plain Lua `require("x")` or missing Rojo mapping)
1559
+ # Negative test 5: Plain non-Roblox Lua file with `require("x")` must NOT fabricate Rojo paths!
1560
+ ev_str = f"Unresolved require({raw_expr}) in {b.file_path}:{b.line}"
1561
+ add_edge(
1562
+ GraphEdge(
1563
+ source=caller_id,
1564
+ target=req_target,
1565
+ relationship="REQUIRES_MODULE",
1566
+ confidence="UNKNOWN",
1567
+ file=b.file_path,
1568
+ start_line=b.line,
1569
+ end_line=b.line,
1570
+ evidence=ev_str,
1571
+ evidence_class=RelationshipEvidenceClass.UNKNOWN.value,
1572
+ reason="unresolved_luau_require",
1573
+ )
1574
+ )
1575
+
1576
+ # 2. Phase R4: ROBLOX_REMOTE_DISPATCH -> CLIENT_DISPATCHES_REMOTE
1577
+ elif b.expr_kind == "ROBLOX_REMOTE_DISPATCH":
1578
+ caller_id = b.source_expr
1579
+ remote_chain = b.target_name
1580
+ fire_method = b.attr_name
1581
+ raw_expr = b.base_expr
1582
+
1583
+ # Negative Test 4: Non-Roblox `:FireServer()` on an arbitrary local table must NOT be a verified Remote!
1584
+ is_verified_roblox_remote = (
1585
+ not b.is_conditional
1586
+ and remote_chain != "<DYNAMIC>"
1587
+ and any(remote_chain.startswith(svc + ".") for svc in _ROBLOX_DATAMODEL_SERVICES)
1588
+ )
1589
+ if is_verified_roblox_remote:
1590
+ ev_cls = (
1591
+ RelationshipEvidenceClass.ROJO_VERIFIED.value
1592
+ if rojo.is_valid
1593
+ else RelationshipEvidenceClass.FRAMEWORK_VERIFIED.value
1594
+ )
1595
+ ev_str = f"Client dispatches remote '{remote_chain}' via :{fire_method}() at {b.file_path}:{b.line}"
1596
+ add_ref(
1597
+ Reference(
1598
+ source_symbol_id=caller_id,
1599
+ target_symbol_id=remote_chain,
1600
+ relationship="CLIENT_DISPATCHES_REMOTE",
1601
+ confidence="HIGH",
1602
+ path=b.file_path,
1603
+ start_line=b.line,
1604
+ end_line=b.line,
1605
+ evidence=ev_str,
1606
+ source_hash=f_hash,
1607
+ indexed_commit=indexed_commit,
1608
+ )
1609
+ )
1610
+ add_edge(
1611
+ GraphEdge(
1612
+ source=caller_id,
1613
+ target=remote_chain,
1614
+ relationship="CLIENT_DISPATCHES_REMOTE",
1615
+ confidence="HIGH",
1616
+ file=b.file_path,
1617
+ start_line=b.line,
1618
+ end_line=b.line,
1619
+ evidence=ev_str,
1620
+ evidence_class=ev_cls,
1621
+ reason=f"roblox_remote_dispatch:{fire_method}",
1622
+ )
1623
+ )
1624
+ elif b.is_conditional or (
1625
+ any(s in raw_expr for s in ("ReplicatedStorage", "Remotes", "WaitForChild"))
1626
+ ):
1627
+ # Dynamic remote lookup -> POSSIBLE / UNKNOWN
1628
+ dyn_target = f"remote.dynamic.{raw_expr}"
1629
+ ev_str = f"Dynamic remote dispatch '{raw_expr}:{fire_method}()' at {b.file_path}:{b.line}"
1630
+ add_edge(
1631
+ GraphEdge(
1632
+ source=caller_id,
1633
+ target=dyn_target,
1634
+ relationship="CLIENT_DISPATCHES_REMOTE",
1635
+ confidence="UNKNOWN",
1636
+ file=b.file_path,
1637
+ start_line=b.line,
1638
+ end_line=b.line,
1639
+ evidence=ev_str,
1640
+ evidence_class=RelationshipEvidenceClass.UNKNOWN.value,
1641
+ reason="dynamic_remote_dispatch",
1642
+ )
1643
+ )
1644
+
1645
+ # 3. Phase R4: ROBLOX_REMOTE_HANDLER -> SERVER_HANDLES_REMOTE
1646
+ elif b.expr_kind == "ROBLOX_REMOTE_HANDLER":
1647
+ handler_id = b.source_expr
1648
+ remote_chain = b.target_name
1649
+ event_prop = b.attr_name
1650
+ raw_expr = b.base_expr
1651
+
1652
+ # Negative Test 3: Non-Roblox `:Connect(...)` is never captured here because regex requires
1653
+ # `.OnServerEvent:Connect` or `.OnClientEvent:Connect`, AND we verify Roblox DataModel root.
1654
+ is_verified_roblox_remote = (
1655
+ not b.is_conditional
1656
+ and remote_chain != "<DYNAMIC>"
1657
+ and any(remote_chain.startswith(svc + ".") for svc in _ROBLOX_DATAMODEL_SERVICES)
1658
+ )
1659
+ if is_verified_roblox_remote:
1660
+ ev_cls = (
1661
+ RelationshipEvidenceClass.ROJO_VERIFIED.value
1662
+ if rojo.is_valid
1663
+ else RelationshipEvidenceClass.FRAMEWORK_VERIFIED.value
1664
+ )
1665
+ ev_str = f"Server handles remote '{remote_chain}' via .{event_prop} -> {handler_id} at {b.file_path}:{b.line}"
1666
+ add_ref(
1667
+ Reference(
1668
+ source_symbol_id=remote_chain,
1669
+ target_symbol_id=handler_id,
1670
+ relationship="SERVER_HANDLES_REMOTE",
1671
+ confidence="HIGH",
1672
+ path=b.file_path,
1673
+ start_line=b.line,
1674
+ end_line=b.line,
1675
+ evidence=ev_str,
1676
+ source_hash=f_hash,
1677
+ indexed_commit=indexed_commit,
1678
+ )
1679
+ )
1680
+ add_edge(
1681
+ GraphEdge(
1682
+ source=remote_chain,
1683
+ target=handler_id,
1684
+ relationship="SERVER_HANDLES_REMOTE",
1685
+ confidence="HIGH",
1686
+ file=b.file_path,
1687
+ start_line=b.line,
1688
+ end_line=b.line,
1689
+ evidence=ev_str,
1690
+ evidence_class=ev_cls,
1691
+ reason=f"roblox_remote_handler:{event_prop}",
1692
+ )
1693
+ )
1694
+ elif b.is_conditional or (
1695
+ any(s in raw_expr for s in ("ReplicatedStorage", "Remotes", "WaitForChild"))
1696
+ ):
1697
+ dyn_target = f"remote.dynamic.{raw_expr}"
1698
+ ev_str = f"Dynamic remote handler '{raw_expr}.{event_prop}' at {b.file_path}:{b.line}"
1699
+ add_edge(
1700
+ GraphEdge(
1701
+ source=dyn_target,
1702
+ target=handler_id,
1703
+ relationship="SERVER_HANDLES_REMOTE",
1704
+ confidence="UNKNOWN",
1705
+ file=b.file_path,
1706
+ start_line=b.line,
1707
+ end_line=b.line,
1708
+ evidence=ev_str,
1709
+ evidence_class=RelationshipEvidenceClass.UNKNOWN.value,
1710
+ reason="dynamic_remote_handler",
1711
+ )
1712
+ )
1713
+
1714
+ # 4. Phase R6: Knit Service Provider & Consumer
1715
+ elif b.expr_kind == "ROBLOX_KNIT_CREATE_SERVICE":
1716
+ provider_canon = b.source_expr
1717
+ svc_name = b.target_name
1718
+ if not b.is_conditional and svc_name != "<DYNAMIC>":
1719
+ ev_str = f"Knit.CreateService provides '{svc_name}' ({provider_canon}) at {b.file_path}:{b.line}"
1720
+ add_ref(
1721
+ Reference(
1722
+ source_symbol_id=provider_canon,
1723
+ target_symbol_id=svc_name,
1724
+ relationship="PROVIDES_SERVICE",
1725
+ confidence="HIGH",
1726
+ path=b.file_path,
1727
+ start_line=b.line,
1728
+ end_line=b.line,
1729
+ evidence=ev_str,
1730
+ source_hash=f_hash,
1731
+ indexed_commit=indexed_commit,
1732
+ )
1733
+ )
1734
+ add_edge(
1735
+ GraphEdge(
1736
+ source=provider_canon,
1737
+ target=svc_name,
1738
+ relationship="PROVIDES_SERVICE",
1739
+ confidence="HIGH",
1740
+ file=b.file_path,
1741
+ start_line=b.line,
1742
+ end_line=b.line,
1743
+ evidence=ev_str,
1744
+ evidence_class=RelationshipEvidenceClass.FRAMEWORK_VERIFIED.value,
1745
+ reason=f"knit_create_service:{svc_name}",
1746
+ )
1747
+ )
1748
+ else:
1749
+ add_edge(
1750
+ GraphEdge(
1751
+ source=provider_canon,
1752
+ target="service.knit.<DYNAMIC>",
1753
+ relationship="PROVIDES_SERVICE",
1754
+ confidence="UNKNOWN",
1755
+ file=b.file_path,
1756
+ start_line=b.line,
1757
+ end_line=b.line,
1758
+ evidence=f"Dynamic Knit.CreateService at {b.file_path}:{b.line}",
1759
+ evidence_class=RelationshipEvidenceClass.UNKNOWN.value,
1760
+ reason="dynamic_knit_service_name",
1761
+ )
1762
+ )
1763
+
1764
+ elif b.expr_kind == "ROBLOX_KNIT_GET_SERVICE":
1765
+ caller_mod = normalize_module(b.file_path)
1766
+ caller_id = f"{caller_mod}.{b.scope}" if b.scope else caller_mod
1767
+ svc_name = b.target_name
1768
+ if not b.is_conditional and svc_name != "<DYNAMIC>":
1769
+ target_provider = service_providers.get(svc_name)
1770
+ if not target_provider and svc_name in short_to_symbols:
1771
+ target_provider = short_to_symbols[svc_name][0].canonical_id
1772
+ resolved_target = target_provider or svc_name
1773
+ ev_str = f"Knit.GetService('{svc_name}') -> '{resolved_target}' at {b.file_path}:{b.line}"
1774
+ add_ref(
1775
+ Reference(
1776
+ source_symbol_id=caller_id,
1777
+ target_symbol_id=resolved_target,
1778
+ relationship="GETS_SERVICE",
1779
+ confidence="HIGH",
1780
+ path=b.file_path,
1781
+ start_line=b.line,
1782
+ end_line=b.line,
1783
+ evidence=ev_str,
1784
+ source_hash=f_hash,
1785
+ indexed_commit=indexed_commit,
1786
+ )
1787
+ )
1788
+ add_edge(
1789
+ GraphEdge(
1790
+ source=caller_id,
1791
+ target=resolved_target,
1792
+ relationship="GETS_SERVICE",
1793
+ confidence="HIGH",
1794
+ file=b.file_path,
1795
+ start_line=b.line,
1796
+ end_line=b.line,
1797
+ evidence=ev_str,
1798
+ evidence_class=RelationshipEvidenceClass.FRAMEWORK_VERIFIED.value,
1799
+ reason=f"knit_get_service:{svc_name}",
1800
+ )
1801
+ )
1802
+ else:
1803
+ add_edge(
1804
+ GraphEdge(
1805
+ source=caller_id,
1806
+ target=f"service.knit.dynamic.{b.source_expr}",
1807
+ relationship="GETS_SERVICE",
1808
+ confidence="UNKNOWN",
1809
+ file=b.file_path,
1810
+ start_line=b.line,
1811
+ end_line=b.line,
1812
+ evidence=f"Dynamic Knit.GetService({b.source_expr}) at {b.file_path}:{b.line}",
1813
+ evidence_class=RelationshipEvidenceClass.UNKNOWN.value,
1814
+ reason="dynamic_knit_get_service",
1815
+ )
1816
+ )
1817
+
1818
+ # 5. Phase R7: Flamework TypeScript @Service, @Controller, Dependency<T>
1819
+ elif b.expr_kind == "ROBLOX_FLAMEWORK_PROVIDER":
1820
+ provider_canon = b.source_expr
1821
+ svc_name = b.target_name
1822
+ ev_str = f"Flamework @{b.attr_name} provides '{svc_name}' ({provider_canon}) at {b.file_path}:{b.line}"
1823
+ add_ref(
1824
+ Reference(
1825
+ source_symbol_id=provider_canon,
1826
+ target_symbol_id=svc_name,
1827
+ relationship="PROVIDES_SERVICE",
1828
+ confidence="HIGH",
1829
+ path=b.file_path,
1830
+ start_line=b.line,
1831
+ end_line=b.line,
1832
+ evidence=ev_str,
1833
+ source_hash=f_hash,
1834
+ indexed_commit=indexed_commit,
1835
+ )
1836
+ )
1837
+ add_edge(
1838
+ GraphEdge(
1839
+ source=provider_canon,
1840
+ target=svc_name,
1841
+ relationship="PROVIDES_SERVICE",
1842
+ confidence="HIGH",
1843
+ file=b.file_path,
1844
+ start_line=b.line,
1845
+ end_line=b.line,
1846
+ evidence=ev_str,
1847
+ evidence_class=RelationshipEvidenceClass.FRAMEWORK_VERIFIED.value,
1848
+ reason=f"flamework_{b.attr_name.lower()}:{svc_name}",
1849
+ )
1850
+ )
1851
+
1852
+ elif b.expr_kind == "ROBLOX_FLAMEWORK_DEPENDENCY":
1853
+ caller_id = b.source_expr
1854
+ dep_type = b.target_name
1855
+ target_provider = service_providers.get(dep_type)
1856
+ if not target_provider and dep_type in short_to_symbols:
1857
+ target_provider = short_to_symbols[dep_type][0].canonical_id
1858
+ resolved_target = target_provider or dep_type
1859
+ ev_str = f"Flamework Dependency<{dep_type}>() -> '{resolved_target}' at {b.file_path}:{b.line}"
1860
+ add_ref(
1861
+ Reference(
1862
+ source_symbol_id=caller_id,
1863
+ target_symbol_id=resolved_target,
1864
+ relationship="GETS_SERVICE",
1865
+ confidence="HIGH",
1866
+ path=b.file_path,
1867
+ start_line=b.line,
1868
+ end_line=b.line,
1869
+ evidence=ev_str,
1870
+ source_hash=f_hash,
1871
+ indexed_commit=indexed_commit,
1872
+ )
1873
+ )
1874
+ add_edge(
1875
+ GraphEdge(
1876
+ source=caller_id,
1877
+ target=resolved_target,
1878
+ relationship="GETS_SERVICE",
1879
+ confidence="HIGH",
1880
+ file=b.file_path,
1881
+ start_line=b.line,
1882
+ end_line=b.line,
1883
+ evidence=ev_str,
1884
+ evidence_class=RelationshipEvidenceClass.FRAMEWORK_VERIFIED.value,
1885
+ reason=f"flamework_dependency:{dep_type}",
1886
+ )
1887
+ )
1888
+
1889
+ # 6. Phase R8: Roblox Persistence (DataStoreService & ProfileService)
1890
+ elif b.expr_kind in (
1891
+ "ROBLOX_CONFIGURE_PERSISTENCE",
1892
+ "ROBLOX_READ_PERSISTENCE",
1893
+ "ROBLOX_WRITE_PERSISTENCE",
1894
+ ):
1895
+ caller_id = b.source_expr
1896
+ store_id = b.target_name
1897
+ op_method = b.attr_name
1898
+ rel_map = {
1899
+ "ROBLOX_CONFIGURE_PERSISTENCE": "CONFIGURES_PERSISTENCE",
1900
+ "ROBLOX_READ_PERSISTENCE": "READS_PERSISTENCE",
1901
+ "ROBLOX_WRITE_PERSISTENCE": "WRITES_PERSISTENCE",
1902
+ }
1903
+ rel_type = rel_map[b.expr_kind]
1904
+ is_dyn = b.is_conditional or store_id.endswith("<DYNAMIC>")
1905
+ ev_cls = (
1906
+ RelationshipEvidenceClass.UNKNOWN.value
1907
+ if is_dyn
1908
+ else RelationshipEvidenceClass.FRAMEWORK_VERIFIED.value
1909
+ )
1910
+ conf = "UNKNOWN" if is_dyn else "HIGH"
1911
+ ev_str = f"Roblox persistence {op_method} on '{store_id}' at {b.file_path}:{b.line}"
1912
+ if not is_dyn:
1913
+ add_ref(
1914
+ Reference(
1915
+ source_symbol_id=caller_id,
1916
+ target_symbol_id=store_id,
1917
+ relationship=rel_type,
1918
+ confidence=conf,
1919
+ path=b.file_path,
1920
+ start_line=b.line,
1921
+ end_line=b.line,
1922
+ evidence=ev_str,
1923
+ source_hash=f_hash,
1924
+ indexed_commit=indexed_commit,
1925
+ )
1926
+ )
1927
+ add_edge(
1928
+ GraphEdge(
1929
+ source=caller_id,
1930
+ target=store_id,
1931
+ relationship=rel_type,
1932
+ confidence=conf,
1933
+ file=b.file_path,
1934
+ start_line=b.line,
1935
+ end_line=b.line,
1936
+ evidence=ev_str,
1937
+ evidence_class=ev_cls,
1938
+ reason=f"roblox_persistence:{op_method}",
1939
+ )
1940
+ )
1941
+
1942
+
1943
+ # ---------------------------------------------------------------------------
1944
+ # Phase R9: CLI and MCP Query Helpers (remotes, modules, routes)
1945
+ # ---------------------------------------------------------------------------
1946
+
1947
+
1948
+ def list_roblox_remotes(con: sqlite3.Connection, repository: Path) -> dict[str, Any]:
1949
+ """List all discovered Roblox RemoteEvent and RemoteFunction channels, client senders, and server handlers."""
1950
+ rows = con.execute(
1951
+ "SELECT source, target, relationship, confidence, file, start_line, evidence, evidence_class "
1952
+ "FROM graph_edges "
1953
+ "WHERE relationship IN ('CLIENT_DISPATCHES_REMOTE', 'SERVER_HANDLES_REMOTE') "
1954
+ "ORDER BY file ASC, start_line ASC"
1955
+ ).fetchall()
1956
+
1957
+ remotes_map: dict[str, dict[str, Any]] = {}
1958
+ for r in rows:
1959
+ rel = str(r["relationship"])
1960
+ remote_id = str(r["target"]) if rel == "CLIENT_DISPATCHES_REMOTE" else str(r["source"])
1961
+ entry = remotes_map.setdefault(
1962
+ remote_id,
1963
+ {
1964
+ "remote": remote_id,
1965
+ "client_dispatches": [],
1966
+ "client_dispatchers": [],
1967
+ "server_handlers": [],
1968
+ "evidence_class": str(r["evidence_class"] or "ROJO_VERIFIED"),
1969
+ },
1970
+ )
1971
+ if rel == "CLIENT_DISPATCHES_REMOTE":
1972
+ item = {
1973
+ "caller": str(r["source"]),
1974
+ "symbol": str(r["source"]),
1975
+ "file": str(r["file"]),
1976
+ "line": int(r["start_line"]),
1977
+ "confidence": str(r["confidence"]),
1978
+ "evidence_class": str(r["evidence_class"]),
1979
+ "evidence": str(r["evidence"]),
1980
+ }
1981
+ entry["client_dispatches"].append(item)
1982
+ entry["client_dispatchers"].append(item)
1983
+ else:
1984
+ entry["server_handlers"].append(
1985
+ {
1986
+ "handler": str(r["target"]),
1987
+ "symbol": str(r["target"]),
1988
+ "file": str(r["file"]),
1989
+ "line": int(r["start_line"]),
1990
+ "confidence": str(r["confidence"]),
1991
+ "evidence_class": str(r["evidence_class"]),
1992
+ "evidence": str(r["evidence"]),
1993
+ }
1994
+ )
1995
+
1996
+ remotes_list = sorted(remotes_map.values(), key=lambda x: str(x["remote"]))
1997
+ return {
1998
+ "status": "ok",
1999
+ "repository": str(repository),
2000
+ "count": len(remotes_list),
2001
+ "remotes": remotes_list,
2002
+ }
2003
+
2004
+
2005
+ def list_roblox_modules(con: sqlite3.Connection, repository: Path) -> dict[str, Any]:
2006
+ """List indexed Luau/Lua modules, Rojo virtual DataModel mappings, and `REQUIRES_MODULE` edges."""
2007
+ rojo = RojoProject.load(repository)
2008
+ file_rows = con.execute(
2009
+ "SELECT path, hash FROM files WHERE language='luau' AND status='ok' ORDER BY path ASC"
2010
+ ).fetchall()
2011
+
2012
+ req_rows = con.execute(
2013
+ "SELECT source, target, confidence, file, start_line, evidence, evidence_class "
2014
+ "FROM graph_edges WHERE relationship='REQUIRES_MODULE' ORDER BY file ASC, start_line ASC"
2015
+ ).fetchall()
2016
+
2017
+ requires_by_file: dict[str, list[dict[str, Any]]] = {}
2018
+ requires = []
2019
+ for r in req_rows:
2020
+ req_item = {
2021
+ "source": str(r["source"]),
2022
+ "target": str(r["target"]),
2023
+ "file": str(r["file"]),
2024
+ "line": int(r["start_line"]),
2025
+ "confidence": str(r["confidence"]),
2026
+ "evidence_class": str(r["evidence_class"]),
2027
+ "evidence": str(r["evidence"]),
2028
+ }
2029
+ requires.append(req_item)
2030
+ requires_by_file.setdefault(str(r["file"]), []).append(req_item)
2031
+
2032
+ modules = []
2033
+ for fr in file_rows:
2034
+ fpath = str(fr["path"])
2035
+ mod_id = normalize_module(fpath, "luau")
2036
+ vpath = rojo.file_to_virtual_path(fpath)
2037
+ modules.append(
2038
+ {
2039
+ "file": fpath,
2040
+ "module": mod_id,
2041
+ "virtual_path": vpath,
2042
+ "rojo_virtual_path": vpath,
2043
+ "evidence_class": "ROJO_VERIFIED" if vpath else "AST_VERIFIED",
2044
+ "requires": requires_by_file.get(fpath, []),
2045
+ }
2046
+ )
2047
+
2048
+ return {
2049
+ "status": "ok",
2050
+ "repository": str(repository),
2051
+ "rojo_configured": rojo.is_valid,
2052
+ "rojo_project": {
2053
+ "is_valid": rojo.is_valid,
2054
+ "name": rojo.name,
2055
+ "project_file": rojo.project_file,
2056
+ "parse_error": rojo.parse_error,
2057
+ "mappings": [
2058
+ {"virtual_path": m.virtual_path, "physical_path": m.physical_path}
2059
+ for m in rojo.mappings
2060
+ ],
2061
+ },
2062
+ "module_count": len(modules),
2063
+ "modules": modules,
2064
+ "requires_count": len(requires),
2065
+ "requires": requires,
2066
+ }
2067
+
2068
+
2069
+ def list_roblox_routes(con: sqlite3.Connection, repository: Path) -> dict[str, Any]:
2070
+ """List end-to-end Roblox Client -> Remote -> Server handler routes, Knit/Flamework service links, and persistence."""
2071
+ remotes_data = list_roblox_remotes(con, repository)
2072
+ routes = []
2073
+ for rem in remotes_data.get("remotes", []):
2074
+ remote_name = rem["remote"]
2075
+ dispatches = rem["client_dispatches"]
2076
+ handlers = rem["server_handlers"]
2077
+ if dispatches and handlers:
2078
+ for d in dispatches:
2079
+ for h in handlers:
2080
+ routes.append(
2081
+ {
2082
+ "client": d["caller"],
2083
+ "client_caller": d["caller"],
2084
+ "client_file": d["file"],
2085
+ "client_line": d["line"],
2086
+ "remote": remote_name,
2087
+ "server": h["handler"],
2088
+ "server_handler": h["handler"],
2089
+ "server_file": h["file"],
2090
+ "server_line": h["line"],
2091
+ "evidence_class": h["evidence_class"],
2092
+ }
2093
+ )
2094
+ elif handlers:
2095
+ for h in handlers:
2096
+ routes.append(
2097
+ {
2098
+ "client": None,
2099
+ "client_caller": None,
2100
+ "client_file": None,
2101
+ "client_line": None,
2102
+ "remote": remote_name,
2103
+ "server": h["handler"],
2104
+ "server_handler": h["handler"],
2105
+ "server_file": h["file"],
2106
+ "server_line": h["line"],
2107
+ "evidence_class": h["evidence_class"],
2108
+ }
2109
+ )
2110
+ elif dispatches:
2111
+ for d in dispatches:
2112
+ routes.append(
2113
+ {
2114
+ "client": d["caller"],
2115
+ "client_caller": d["caller"],
2116
+ "client_file": d["file"],
2117
+ "client_line": d["line"],
2118
+ "remote": remote_name,
2119
+ "server": None,
2120
+ "server_handler": None,
2121
+ "server_file": None,
2122
+ "server_line": None,
2123
+ "evidence_class": d["evidence_class"],
2124
+ }
2125
+ )
2126
+
2127
+ svc_rows = con.execute(
2128
+ "SELECT source, target, relationship, confidence, file, start_line, evidence_class "
2129
+ "FROM graph_edges WHERE relationship IN ('PROVIDES_SERVICE', 'GETS_SERVICE') "
2130
+ "ORDER BY file ASC, start_line ASC"
2131
+ ).fetchall()
2132
+ services = [
2133
+ {
2134
+ "provider": str(r["source"]),
2135
+ "source": str(r["source"]),
2136
+ "service": str(r["target"]),
2137
+ "target": str(r["target"]),
2138
+ "relationship": str(r["relationship"]),
2139
+ "file": str(r["file"]),
2140
+ "line": int(r["start_line"]),
2141
+ "confidence": str(r["confidence"]),
2142
+ "evidence_class": str(r["evidence_class"]),
2143
+ }
2144
+ for r in svc_rows
2145
+ ]
2146
+
2147
+ pers_rows = con.execute(
2148
+ "SELECT source, target, relationship, confidence, file, start_line, evidence_class "
2149
+ "FROM graph_edges WHERE relationship IN ('CONFIGURES_PERSISTENCE', 'READS_PERSISTENCE', 'WRITES_PERSISTENCE') "
2150
+ "ORDER BY file ASC, start_line ASC"
2151
+ ).fetchall()
2152
+ persistence = [
2153
+ {
2154
+ "symbol": str(r["source"]),
2155
+ "store": str(r["target"]),
2156
+ "relationship": str(r["relationship"]),
2157
+ "file": str(r["file"]),
2158
+ "line": int(r["start_line"]),
2159
+ "confidence": str(r["confidence"]),
2160
+ "evidence_class": str(r["evidence_class"]),
2161
+ }
2162
+ for r in pers_rows
2163
+ ]
2164
+
2165
+ return {
2166
+ "status": "ok",
2167
+ "repository": str(repository),
2168
+ "route_count": len(routes),
2169
+ "routes": routes,
2170
+ "services": services,
2171
+ "service_edges": services,
2172
+ "persistence": persistence,
2173
+ }
2174
+