cortex-local 2.6.0__tar.gz → 2.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {cortex_local-2.6.0 → cortex_local-2.7.1}/PKG-INFO +2 -1
  2. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/core/chunker.py +56 -1
  3. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/core/code_graph.py +18 -0
  4. cortex_local-2.7.1/cortex/core/godot.py +298 -0
  5. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/mcp_server.py +2 -1
  6. {cortex_local-2.6.0 → cortex_local-2.7.1}/pyproject.toml +1 -0
  7. {cortex_local-2.6.0 → cortex_local-2.7.1}/.gitignore +0 -0
  8. {cortex_local-2.6.0 → cortex_local-2.7.1}/README.md +0 -0
  9. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/__init__.py +0 -0
  10. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/cli.py +0 -0
  11. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/config.py +0 -0
  12. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/core/__init__.py +0 -0
  13. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/core/cache.py +0 -0
  14. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/core/formatters.py +0 -0
  15. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/core/global_graph.py +0 -0
  16. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/core/graph.py +0 -0
  17. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/core/notes_graph.py +0 -0
  18. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/core/roslyn_analyzer.py +0 -0
  19. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/core/roslyn_tool/CortexAnalyzer.csproj +0 -0
  20. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/core/roslyn_tool/Program.cs +0 -0
  21. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/db.py +0 -0
  22. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/embedder.py +0 -0
  23. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/indexer.py +0 -0
  24. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/state.py +0 -0
  25. {cortex_local-2.6.0 → cortex_local-2.7.1}/cortex/watcher.py +0 -0
  26. {cortex_local-2.6.0 → cortex_local-2.7.1}/hatch_build.py +0 -0
  27. {cortex_local-2.6.0 → cortex_local-2.7.1}/tests/test_imports.py +0 -0
  28. {cortex_local-2.6.0 → cortex_local-2.7.1}/tests/test_stdio_init.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cortex-local
3
- Version: 2.6.0
3
+ Version: 2.7.1
4
4
  Summary: Local semantic search MCP server — notes + code, embedded Qdrant, fastembed
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: click>=8.0.0
@@ -10,6 +10,7 @@ Requires-Dist: networkx>=3.0
10
10
  Requires-Dist: qdrant-client>=1.17.0
11
11
  Requires-Dist: tqdm>=4.0
12
12
  Requires-Dist: tree-sitter-c-sharp
13
+ Requires-Dist: tree-sitter-gdscript
13
14
  Requires-Dist: tree-sitter-javascript
14
15
  Requires-Dist: tree-sitter-kotlin
15
16
  Requires-Dist: tree-sitter-python
@@ -6,6 +6,11 @@ import os
6
6
  import re as _re
7
7
  from pathlib import Path
8
8
 
9
+ try:
10
+ from . import godot as _godot
11
+ except ImportError: # server: core/ is on sys.path as top-level modules
12
+ import godot as _godot
13
+
9
14
  _RAZOR_BLOCK_RE = _re.compile(r'@(?:code|functions)\s*\{', _re.IGNORECASE)
10
15
 
11
16
 
@@ -32,7 +37,8 @@ def _extract_razor_cs_blocks(source: str) -> list:
32
37
 
33
38
  REPOS_DIR = Path(os.environ.get("REPOS_DIR", "/tmp/repos"))
34
39
 
35
- CODE_EXTS = {".js", ".ts", ".tsx", ".jsx", ".svelte", ".py", ".java", ".go", ".rs", ".css", ".html", ".kt", ".kts", ".gd", ".yml", ".yaml", ".cs", ".razor", ".cshtml"}
40
+ CODE_EXTS = {".js", ".ts", ".tsx", ".jsx", ".svelte", ".py", ".java", ".go", ".rs", ".css", ".html", ".kt", ".kts", ".gd", ".yml", ".yaml", ".cs", ".razor", ".cshtml",
41
+ ".tscn", ".tres", ".godot", ".gdshader", ".dialogue"}
36
42
  SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".svelte-kit", "__pycache__", ".gradle", "target", "bin", "obj", ".godot"}
37
43
  LANG_MAP = {
38
44
  ".js": "javascript", ".ts": "typescript", ".tsx": "typescript",
@@ -41,6 +47,8 @@ LANG_MAP = {
41
47
  ".kt": "kotlin", ".kts": "kotlin", ".gd": "gdscript",
42
48
  ".yml": "yaml", ".yaml": "yaml", ".cs": "csharp",
43
49
  ".razor": "razor", ".cshtml": "cshtml",
50
+ ".tscn": "godot-scene", ".tres": "godot-resource", ".godot": "godot-project",
51
+ ".gdshader": "gdshader", ".dialogue": "dialogue",
44
52
  }
45
53
 
46
54
  def _is_excluded(rel: str, patterns: list) -> bool:
@@ -297,6 +305,8 @@ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_
297
305
  rel = path.name
298
306
  language = LANG_MAP.get(ext, ext.lstrip("."))
299
307
  extra = _extra_tokens(rel, lines)
308
+ if ext == ".gd":
309
+ extra = (extra + " " + _godot.gd_context(source_text)).strip()
300
310
 
301
311
  def _header(rel: str, s: int, e: int, tokens: str) -> str:
302
312
  h = f"# {repo_name}/{rel} (lines {s}-{e})\n# {tokens}"
@@ -331,6 +341,29 @@ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_
331
341
  })
332
342
  return chunks
333
343
 
344
+ # Godot scenes/resources/project: index a readable summary, not the raw serialization
345
+ if ext in _godot.GODOT_SUMMARY_EXTS:
346
+ summary = _godot.summarize(path, source_text).splitlines()
347
+ if not summary:
348
+ return []
349
+ tokens = _path_tokens(repo_name, rel)
350
+ n_file = len(lines)
351
+ windows = [summary[i:i + MAX_CHUNK_LINES] for i in range(0, len(summary), MAX_CHUNK_LINES)]
352
+ k = len(windows)
353
+ out = []
354
+ for i, w in enumerate(windows):
355
+ # distinct pseudo line ranges per window (chunk ids are keyed on start_line)
356
+ s_line = 1 + (i * n_file) // k
357
+ e_line = max(s_line, ((i + 1) * n_file) // k)
358
+ url = f"{github_url_base}/{rel}#L{s_line}-L{e_line}" if github_url_base else ""
359
+ out.append({
360
+ "repo": repo_name, "file": rel, "language": language,
361
+ "start_line": s_line, "end_line": e_line,
362
+ "text": f"{_header(rel, s_line, e_line, tokens)}\n\n" + "\n".join(w),
363
+ "github_url": url,
364
+ })
365
+ return out
366
+
334
367
  # Small files: keep as single chunk to avoid diluting path/filename signal
335
368
  if len(lines) <= MAX_CHUNK_LINES:
336
369
  body = "\n".join(lines).strip()
@@ -371,6 +404,28 @@ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_
371
404
  if chunks:
372
405
  return chunks
373
406
 
407
+ # GDScript: functions / inner classes / member blocks, annotations kept with their decl
408
+ if ext == ".gd":
409
+ spans = _godot.gd_spans(raw)
410
+ if spans:
411
+ chunks = []
412
+ tokens = _path_tokens(repo_name, rel)
413
+ for s0, e, _kind in spans:
414
+ if e - s0 > MAX_CHUNK_LINES:
415
+ chunks.extend(_sliding_window(lines, s0, e, repo_name, rel, language, github_url_base, extra))
416
+ continue
417
+ body = "\n".join(lines[s0:e]).strip()
418
+ if body:
419
+ url = f"{github_url_base}/{rel}#L{s0+1}-L{e}" if github_url_base else ""
420
+ chunks.append({
421
+ "repo": repo_name, "file": rel, "language": language,
422
+ "start_line": s0 + 1, "end_line": e,
423
+ "text": f"{_header(rel, s0+1, e, tokens)}\n\n{body}",
424
+ "github_url": url,
425
+ })
426
+ if chunks:
427
+ return chunks
428
+
374
429
  if _TS_AVAILABLE and ext in _TS_LANGUAGES:
375
430
  try:
376
431
  parser = _TSParser(_TS_LANGUAGES[ext])
@@ -8,6 +8,13 @@ from pathlib import Path
8
8
 
9
9
  log = logging.getLogger("cortex.graph")
10
10
 
11
+ try:
12
+ from . import godot as _godot
13
+ except ImportError: # server: core/ is on sys.path as top-level modules
14
+ import godot as _godot
15
+
16
+ _GODOT_EXTS = (".gd", ".tscn", ".tres", ".godot")
17
+
11
18
  try:
12
19
  import networkx as nx
13
20
  _NX_AVAILABLE = True
@@ -336,6 +343,17 @@ def build_code_graph(repo_path: Path, all_files: set):
336
343
  _add_edge(G, rel, target, "call")
337
344
  for target in _extract_inheritance_targets(path, ext, all_files, rel, symbol_to_file):
338
345
  _add_edge(G, rel, target, "inherits")
346
+ godot_files = [f for f in all_files if f.endswith(_GODOT_EXTS)]
347
+ if godot_files:
348
+ proj = _godot.GodotProject(repo_path, all_files)
349
+ for rel in godot_files:
350
+ try:
351
+ text = (repo_path / rel).read_text(encoding="utf-8", errors="replace")
352
+ except Exception:
353
+ continue
354
+ for target, kind in _godot.gd_edges(rel, text, proj):
355
+ if target in all_files:
356
+ _add_edge(G, rel, target, kind)
339
357
  return G
340
358
 
341
359
 
@@ -0,0 +1,298 @@
1
+ """Godot support: GDScript semantic chunks, scene/resource/project summaries, graph edges.
2
+
3
+ Used by chunker.chunk_file (chunks) and code_graph.build_code_graph (edges). Paths handed in
4
+ and out are repo-relative POSIX; `res://` paths are resolved against the directory that holds
5
+ project.godot (the Godot project root, not necessarily the repo root).
6
+ """
7
+
8
+ import re
9
+ from pathlib import Path
10
+
11
+ try:
12
+ from tree_sitter import Language, Parser
13
+ import tree_sitter_gdscript as _tsgd
14
+ _GD_LANG = Language(_tsgd.language())
15
+ except Exception: # grammar missing -> chunker falls back to the sliding window
16
+ _GD_LANG = None
17
+
18
+ GODOT_SUMMARY_EXTS = {".tscn", ".tres", ".godot"}
19
+
20
+ # ── res:// / uid:// resolution ───────────────────────────────────────────────
21
+
22
+ _SECTION_RE = re.compile(r'^\[(\w+)([^\]\n]*)\][ \t]*$', re.MULTILINE) # single line: no cross-line backtracking
23
+ MAX_SUMMARY_INPUT = 2_000_000 # chars; larger scenes/resources are truncated before parsing
24
+ _ATTR_RE = re.compile(r'(\w+)=("([^"]*)"|[^\s\]]+)')
25
+
26
+
27
+ def _attrs(s: str) -> dict:
28
+ return {m.group(1): (m.group(3) if m.group(3) is not None else m.group(2)) for m in _ATTR_RE.finditer(s)}
29
+
30
+
31
+ class GodotProject:
32
+ """Repo-level Godot context: project roots, autoloads, uid map, class_name map."""
33
+
34
+ def __init__(self, repo_path: Path, all_files: set):
35
+ self.roots = sorted({str(Path(f).parent.as_posix()) for f in all_files if Path(f).name == "project.godot"},
36
+ key=len, reverse=True)
37
+ self.autoloads: dict = {} # Name -> repo-relative file
38
+ self.uids: dict = {} # uid://x -> repo-relative file
39
+ self.classes: dict = {} # class_name -> repo-relative file
40
+ for f in all_files:
41
+ p = repo_path / f
42
+ if Path(f).name == "project.godot":
43
+ for name, target in _parse_autoloads(_read(p)).items():
44
+ resolved = self.res(target, f)
45
+ if resolved:
46
+ self.autoloads[name] = resolved
47
+ elif f.endswith(".gd"):
48
+ m = re.search(r'^class_name\s+(\w+)', _read(p), re.MULTILINE)
49
+ if m:
50
+ self.classes.setdefault(m.group(1), f)
51
+ if f.endswith((".tscn", ".tres")):
52
+ head = _read(p, 400)
53
+ m = re.search(r'uid="(uid://[^"]+)"', head.split("\n", 1)[0])
54
+ if m:
55
+ self.uids[m.group(1)] = f
56
+ # `.uid` twin files (Godot 4.4+): script.gd.uid contains the script's uid
57
+ for uidfile in repo_path.rglob("*.uid"):
58
+ try:
59
+ rel = uidfile.relative_to(repo_path).as_posix()[:-4]
60
+ if rel in all_files:
61
+ self.uids[uidfile.read_text(errors="replace").strip()] = rel
62
+ except Exception:
63
+ continue
64
+
65
+ def project_root(self, rel: str) -> str:
66
+ for r in self.roots:
67
+ if r == "." or rel.startswith(r + "/"):
68
+ return r
69
+ return "."
70
+
71
+ def res(self, path: str, from_file: str) -> str | None:
72
+ """res://a/b.gd (or uid://...) -> repo-relative path, None if unknown."""
73
+ path = path.strip().lstrip("*")
74
+ if path.startswith("uid://"):
75
+ return self.uids.get(path)
76
+ if not path.startswith("res://"):
77
+ return None
78
+ root = self.project_root(from_file)
79
+ tail = path[len("res://"):]
80
+ return tail if root == "." else f"{root}/{tail}"
81
+
82
+
83
+ def _read(p: Path, limit: int | None = None) -> str:
84
+ try:
85
+ text = p.read_text(encoding="utf-8", errors="replace")
86
+ return text[:limit] if limit else text
87
+ except Exception:
88
+ return ""
89
+
90
+
91
+ def _parse_autoloads(text: str) -> dict:
92
+ out, in_section = {}, False
93
+ for line in text.splitlines():
94
+ s = line.strip()
95
+ if s.startswith("["):
96
+ in_section = s == "[autoload]"
97
+ continue
98
+ if in_section and "=" in s:
99
+ k, v = s.split("=", 1)
100
+ out[k.strip()] = v.strip().strip('"')
101
+ return out
102
+
103
+
104
+ # ── GDScript chunking ────────────────────────────────────────────────────────
105
+
106
+ _DECL_TYPES = {"signal_statement", "variable_statement", "const_statement", "enum_definition",
107
+ "export_variable_statement", "onready_variable_statement"}
108
+
109
+
110
+ def gd_context(source: str) -> str:
111
+ """'class_name X extends Y' for embedding headers ('' when the file declares neither)."""
112
+ cn = re.search(r'^class_name\s+(\w+)', source, re.MULTILINE)
113
+ ex = re.search(r'^extends\s+(\S+)', source, re.MULTILINE)
114
+ parts = []
115
+ if cn:
116
+ parts.append(f"class_name {cn.group(1)}")
117
+ if ex:
118
+ parts.append(f"extends {ex.group(1)}")
119
+ return " ".join(parts)
120
+
121
+
122
+ def gd_spans(raw: bytes) -> list | None:
123
+ """Top-level semantic spans [(start_line0, end_line_excl, kind)], or None without the grammar.
124
+
125
+ Functions and inner classes are their own spans, with preceding annotations (@rpc,
126
+ @warning_ignore ...) attached. Runs of signals/vars/consts/enums form one 'members' span.
127
+ """
128
+ if _GD_LANG is None:
129
+ return None
130
+ try:
131
+ tree = Parser(_GD_LANG).parse(raw)
132
+ except Exception:
133
+ return None
134
+ spans: list = []
135
+ pending_ann = None # start line of annotations waiting for their declaration
136
+ decl = None # [start, end] of the current members run
137
+ for node in tree.root_node.children:
138
+ t = node.type
139
+ s, e = node.start_point[0], node.end_point[0] + 1
140
+ if t == "annotation":
141
+ pending_ann = s if pending_ann is None else pending_ann
142
+ continue
143
+ start = pending_ann if pending_ann is not None else s
144
+ pending_ann = None
145
+ if t in ("function_definition", "class_definition", "constructor_definition"):
146
+ if decl:
147
+ spans.append((decl[0], decl[1], "members")); decl = None
148
+ spans.append((start, e, "class" if t == "class_definition" else "function"))
149
+ elif t in _DECL_TYPES:
150
+ decl = [decl[0], e] if decl else [start, e]
151
+ # class_name / extends / comments: covered by the header context
152
+ if decl:
153
+ spans.append((decl[0], decl[1], "members"))
154
+ return spans
155
+
156
+
157
+ # ── Scene / resource / project summaries (indexed instead of raw text) ──────
158
+
159
+ def _ext_resources(text: str) -> dict:
160
+ out = {}
161
+ for m in _SECTION_RE.finditer(text):
162
+ if m.group(1) == "ext_resource":
163
+ a = _attrs(m.group(2))
164
+ if "id" in a:
165
+ out[a["id"]] = a.get("path") or a.get("uid", "")
166
+ return out
167
+
168
+
169
+ def _sections(text: str):
170
+ """Yield (kind, attrs, body_lines) for each [section] in a .tscn/.tres file."""
171
+ matches = list(_SECTION_RE.finditer(text))
172
+ for i, m in enumerate(matches):
173
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
174
+ body = text[m.end():end].strip("\n").splitlines()
175
+ yield m.group(1), _attrs(m.group(2)), body
176
+
177
+
178
+ _EXTRES_REF = re.compile(r'ExtResource\(\s*"?([^")\s]+)"?\s*\)')
179
+ _SKIP_VALUE = re.compile(r'^(Packed\w*Array|PoolByteArray|Image)\(')
180
+
181
+
182
+ def summarize_tscn(text: str) -> str:
183
+ ext = _ext_resources(text)
184
+ lines = ["Godot scene", "Nodes:"]
185
+ conns = []
186
+ for kind, a, body in _sections(text):
187
+ if kind == "node":
188
+ name, parent = a.get("name", "?"), a.get("parent")
189
+ path = name if parent is None else (name if parent == "." else f"{parent}/{name}")
190
+ desc = f" {path}"
191
+ if a.get("type"):
192
+ desc += f" ({a['type']})"
193
+ inst = _EXTRES_REF.search(a.get("instance", ""))
194
+ if inst:
195
+ desc += f" instance={ext.get(inst.group(1), inst.group(1))}"
196
+ for line in body:
197
+ if line.startswith("script ="):
198
+ ref = _EXTRES_REF.search(line)
199
+ if ref:
200
+ desc += f" script={ext.get(ref.group(1), ref.group(1))}"
201
+ lines.append(desc)
202
+ elif kind == "connection":
203
+ conns.append(f" {a.get('from', '?')}.{a.get('signal', '?')} -> {a.get('method', '?')} (on {a.get('to', '?')})")
204
+ if conns:
205
+ lines.append("Signal connections:")
206
+ lines += conns
207
+ if ext:
208
+ lines.append("Uses: " + ", ".join(sorted(set(v for v in ext.values() if v))))
209
+ return "\n".join(lines)
210
+
211
+
212
+ def summarize_tres(text: str) -> str:
213
+ ext = _ext_resources(text)
214
+ lines = []
215
+ for kind, a, body in _sections(text):
216
+ if kind == "gd_resource":
217
+ head = f"Godot resource type={a.get('type', '?')}"
218
+ if a.get("script_class"):
219
+ head += f" script_class={a['script_class']}"
220
+ lines.append(head)
221
+ elif kind in ("resource", "sub_resource"):
222
+ if kind == "sub_resource":
223
+ lines.append(f"Sub-resource {a.get('type', '')} {a.get('id', '')}".rstrip())
224
+ for line in body:
225
+ if "=" not in line:
226
+ continue
227
+ k, v = (x.strip() for x in line.split("=", 1))
228
+ if _SKIP_VALUE.match(v):
229
+ continue
230
+ v = _EXTRES_REF.sub(lambda m: ext.get(m.group(1), m.group(1)), v)
231
+ lines.append(f" {k} = {v[:200]}")
232
+ if ext:
233
+ lines.append("Uses: " + ", ".join(sorted(set(v for v in ext.values() if v))))
234
+ return "\n".join(lines)
235
+
236
+
237
+ def summarize_project(text: str) -> str:
238
+ lines, section, actions = ["Godot project.godot"], "", []
239
+ for raw in text.splitlines():
240
+ s = raw.strip()
241
+ if s.startswith("["):
242
+ section = s.strip("[]")
243
+ continue
244
+ if "=" not in s or s.startswith(";"):
245
+ continue
246
+ k, v = (x.strip() for x in s.split("=", 1))
247
+ if section == "application" and k in ("config/name", "run/main_scene", "config/features"):
248
+ lines.append(f"{k} = {v}")
249
+ elif section == "autoload":
250
+ lines.append(f"autoload {k} = {v.strip(chr(34)).lstrip('*')}")
251
+ elif section == "input" and not k.startswith(" "):
252
+ actions.append(k)
253
+ if actions:
254
+ lines.append("Input actions: " + ", ".join(actions))
255
+ return "\n".join(lines)
256
+
257
+
258
+ def summarize(path: Path, text: str) -> str:
259
+ text = text[:MAX_SUMMARY_INPUT]
260
+ if path.suffix == ".tscn":
261
+ return summarize_tscn(text)
262
+ if path.suffix == ".tres":
263
+ return summarize_tres(text)
264
+ return summarize_project(text)
265
+
266
+
267
+ # ── Graph edges ──────────────────────────────────────────────────────────────
268
+
269
+ _RES_STR = re.compile(r'"\*?((?:res|uid)://[^"]+)"')
270
+ _LOAD_RE = re.compile(r'\b(?:preload|load|ResourceLoader\.load)\s*\(\s*"((?:res|uid)://[^"]+)"')
271
+ _EXTENDS_RE = re.compile(r'^extends\s+("([^"]+)"|(\w+))', re.MULTILINE)
272
+ _USE_RE = re.compile(r'\b([A-Z]\w*)\s*(?:\.|\(|$)|(?::|\bas|\bis|->)\s*([A-Z]\w*)', re.MULTILINE)
273
+
274
+
275
+ def gd_edges(rel: str, text: str, proj: GodotProject) -> list:
276
+ """[(target, edge_type)] for a .gd / .tscn / .tres / project.godot file."""
277
+ edges: list = []
278
+ if rel.endswith(".gd"):
279
+ m = _EXTENDS_RE.search(text)
280
+ if m:
281
+ tgt = proj.res(m.group(2), rel) if m.group(2) else proj.classes.get(m.group(3))
282
+ if tgt:
283
+ edges.append((tgt, "inherits"))
284
+ for m in _LOAD_RE.finditer(text):
285
+ tgt = proj.res(m.group(1), rel)
286
+ if tgt:
287
+ edges.append((tgt, "import"))
288
+ used = {a or b for a, b in _USE_RE.findall(text)}
289
+ for name in used:
290
+ tgt = proj.classes.get(name) or proj.autoloads.get(name)
291
+ if tgt:
292
+ edges.append((tgt, "call"))
293
+ else: # scenes/resources/project: every res:// reference is a dependency
294
+ for m in _RES_STR.finditer(text):
295
+ tgt = proj.res(m.group(1), rel)
296
+ if tgt:
297
+ edges.append((tgt, "import"))
298
+ return [(t, k) for t, k in edges if t != rel]
@@ -369,9 +369,10 @@ def get_neighbors(file: str, repo: str) -> str:
369
369
  meta = get_graph_meta(repo)
370
370
  if not meta:
371
371
  return f"No graph data for '{repo}'. Run reindex() first."
372
+ file = file.removeprefix("res://") # Godot paths: res:// is relative to the project root
372
373
  file_meta = meta.get(file)
373
374
  if file_meta is None:
374
- matches = [f for f in meta if file in f]
375
+ matches = sorted((f for f in meta if f.endswith("/" + file) or file in f), key=len)
375
376
  if not matches:
376
377
  return f"File '{file}' not found in graph for '{repo}'."
377
378
  file = matches[0]
@@ -22,6 +22,7 @@ dependencies = [
22
22
  "tree-sitter-typescript",
23
23
  "tree-sitter-kotlin",
24
24
  "tree-sitter-c-sharp",
25
+ "tree-sitter-gdscript",
25
26
  ]
26
27
 
27
28
  [project.scripts]
File without changes
File without changes
File without changes
File without changes