codegraph-engine 3.0.0__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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +859 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|