codeui-python 0.1.0__py3-none-any.whl → 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. codeui/__init__.py +1 -1
  2. codeui/agent/context.py +19 -3
  3. codeui/analysis/dead_code.py +13 -2
  4. codeui/analysis/undefined.py +70 -0
  5. codeui/analysis/unresolved_import.py +34 -4
  6. codeui/cli/main.py +101 -7
  7. codeui/core/cache.py +54 -0
  8. codeui/core/graph.py +233 -3
  9. codeui/core/override.py +1 -0
  10. codeui/core/resolver.py +51 -37
  11. codeui/errors.py +10 -0
  12. codeui/lang/base.py +5 -2
  13. codeui/lang/cpp.py +271 -0
  14. codeui/lang/csharp.py +262 -0
  15. codeui/lang/dart.py +278 -0
  16. codeui/lang/generic.py +6 -4
  17. codeui/lang/go.py +5 -2
  18. codeui/lang/java.py +320 -0
  19. codeui/lang/kotlin.py +303 -0
  20. codeui/lang/php.py +275 -0
  21. codeui/lang/python.py +49 -7
  22. codeui/lang/registry.py +22 -2
  23. codeui/lang/ruby.py +270 -0
  24. codeui/lang/rust.py +5 -2
  25. codeui/lang/scala.py +290 -0
  26. codeui/lang/swift.py +276 -0
  27. codeui/lang/ts.py +99 -8
  28. codeui/report/sarif_emitter.py +2 -1
  29. codeui/server/server.py +156 -85
  30. codeui/server/static/css/styles.css +1609 -0
  31. codeui/server/static/index.html +199 -0
  32. codeui/server/static/js/api.js +485 -0
  33. codeui/server/static/js/app.js +1044 -0
  34. codeui/server/static/js/graph_canvas.js +1485 -0
  35. codeui/server/static/js/minimap.js +383 -0
  36. codeui/server/static/js/sidebar.js +268 -0
  37. codeui/server/static/js/state.js +83 -0
  38. codeui/server/static/js/subgraph_window.js +1208 -0
  39. codeui/server/static/js/theme.js +332 -0
  40. codeui/tracer/__init__.py +4 -1
  41. codeui/tracer/tracer.py +109 -118
  42. codeui_python-0.1.1.dist-info/METADATA +171 -0
  43. codeui_python-0.1.1.dist-info/RECORD +71 -0
  44. {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/licenses/LICENSE +1 -1
  45. codeui_python-0.1.0.dist-info/METADATA +0 -191
  46. codeui_python-0.1.0.dist-info/RECORD +0 -52
  47. {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/WHEEL +0 -0
  48. {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/entry_points.txt +0 -0
  49. {codeui_python-0.1.0.dist-info → codeui_python-0.1.1.dist-info}/top_level.txt +0 -0
codeui/core/graph.py CHANGED
@@ -88,7 +88,7 @@ def classify_file(file_path: str, entry_points: Optional[Iterable[str]] = None,
88
88
  shared_kw = {"utils", "util", "common", "shared", "helpers", "helper", "types", "constants", "errors", "exceptions"}
89
89
  hooks_kw = {"hooks", "composables"}
90
90
  frontend_kw = {"components", "screens", "views", "pages", "features", "ui", "frontend", "styles", "widgets", "assets", "static", "public", "templates", "client", "web"}
91
- backend_kw = {"server", "services", "service", "api", "backend", "domain", "data", "db", "models", "model", "controllers", "controller", "routes", "route", "handler", "handlers", "repository", "queries", "libraries", "srv", "analysis", "report", "plugins", "lang", "tracer", "agent"}
91
+ backend_kw = {"server", "services", "service", "api", "backend", "domain", "data", "db", "models", "model", "controllers", "controller", "routes", "route", "handler", "handlers", "repository", "queries", "libraries", "srv", "analysis", "report", "plugins", "lang", "tracer", "agent","lib"}
92
92
 
93
93
  is_hook_stem = (stem.startswith("use") and len(stem) > 3 and stem[3].isupper() and name_lower.endswith((".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte"))) or (stem.startswith("use_") and name_lower.endswith(".py") and any(p in ("hooks", "composables") for p in parts_lower))
94
94
 
@@ -293,6 +293,19 @@ class Graph:
293
293
  symbols = [self._symbols[sid] for sid in sym_ids if sid in self._symbols]
294
294
  return sorted(symbols, key=lambda s: (s.location.start_line if s.location else 0, s.id))
295
295
 
296
+ def has_file(self, file_id: str) -> bool:
297
+ """Check if file exists in the code graph.
298
+ Example:
299
+ >>> g = Graph()
300
+ >>> g.has_file("main.py")
301
+ False
302
+ """
303
+ clean = file_id.replace("\\", "/").lstrip("./")
304
+ for f in self._file_symbols:
305
+ if f.replace("\\", "/").lstrip("./") == clean or f == file_id:
306
+ return True
307
+ return False
308
+
296
309
  def get_all_files(self) -> List[str]:
297
310
  """Return list of all analyzed file paths sorted.
298
311
  Example:
@@ -311,6 +324,15 @@ class Graph:
311
324
  """
312
325
  return sorted(self._edges, key=lambda e: (e.source_id, e.target_id, str(e.kind)))
313
326
 
327
+ def get_all_edges(self) -> List[Edge]:
328
+ """Return all graph edges sorted deterministically (alias for get_edges).
329
+ Example:
330
+ >>> g = Graph()
331
+ >>> g.get_all_edges()
332
+ []
333
+ """
334
+ return self.get_edges()
335
+
314
336
  def get_outgoing_edges(self, source_id: str, kind: EdgeKind | None = None) -> List[Edge]:
315
337
  """Return outgoing edges from source symbol.
316
338
  Example:
@@ -335,6 +357,203 @@ class Graph:
335
357
  edges = [e for e in edges if e.kind == kind]
336
358
  return sorted(edges, key=lambda e: (e.source_id, e.target_id, str(e.kind)))
337
359
 
360
+ def get_file_subgraph(self, target_id: str, include_cross: bool = True, max_hops: Optional[int] = None) -> Dict[str, Any]:
361
+ """Extract a sub-graph for a file or external symbol containing its symbols and related cross-file connections.
362
+ Example:
363
+ >>> g = Graph()
364
+ >>> loc = Location("app.py", 1, 0, 5, 0)
365
+ >>> g.add_symbol(Symbol("app.py::main", "main", "main", SymbolKind.FUNCTION, "python", loc, None, "def main()", Visibility.PUBLIC, (), "h1"))
366
+ >>> res = g.get_file_subgraph("app.py")
367
+ >>> res["file_id"]
368
+ 'app.py'
369
+ """
370
+ clean_target = target_id.replace("module::", "").replace("\\", "/").strip().lstrip("./")
371
+ target_file: Optional[str] = None
372
+ for f in self._file_symbols:
373
+ if f.replace("\\", "/").lstrip("./") == clean_target or f == target_id:
374
+ target_file = f
375
+ break
376
+
377
+ if not target_file and target_id in self._symbols:
378
+ sym = self._symbols[target_id]
379
+ if sym.location and sym.location.file_id:
380
+ target_file = sym.location.file_id
381
+
382
+ if not target_file:
383
+ for s in self._symbols.values():
384
+ if s.id == target_id or s.name == clean_target or s.qualified_name == clean_target or s.id.endswith("::" + clean_target):
385
+ if s.location and s.location.file_id:
386
+ target_file = s.location.file_id
387
+ break
388
+
389
+ if target_file and target_file in self._file_symbols:
390
+ raw_symbols = self.get_symbols_by_file(target_file)
391
+ symbols = [s for s in raw_symbols if s.kind not in (SymbolKind.FILE, SymbolKind.VARIABLE)]
392
+ if not symbols:
393
+ symbols = [s for s in raw_symbols if s.kind != SymbolKind.FILE]
394
+ if not symbols:
395
+ symbols = raw_symbols
396
+ sym_ids = {s.id for s in symbols}
397
+ cls = self.get_file_classification(target_file)
398
+
399
+ intra_edges = [
400
+ e for e in self._edges
401
+ if (e.source_id in sym_ids or e.source_id == target_file) and (e.target_id in sym_ids or e.target_id == target_file)
402
+ ]
403
+
404
+ related_symbols_dict: Dict[str, Dict[str, Any]] = {}
405
+ collected_cross_edges: Set[Tuple[str, str, str]] = set()
406
+ ext_edges: List[Edge] = []
407
+
408
+ if include_cross:
409
+ frontier: Set[str] = set(sym_ids)
410
+ visited_symbols: Set[str] = set(sym_ids)
411
+ hops = 0
412
+ max_traversal_hops = max_hops if max_hops is not None else 1000
413
+
414
+ file_outgoing = self.get_outgoing_edges(target_file)
415
+ file_incoming = self.get_incoming_edges(target_file)
416
+ for e in file_outgoing + file_incoming:
417
+ endpoint = e.target_id if e.source_id == target_file else e.source_id
418
+ if endpoint in self._file_symbols and endpoint != target_file:
419
+ edge_key = (e.source_id, e.target_id, str(e.kind))
420
+ if edge_key not in collected_cross_edges:
421
+ collected_cross_edges.add(edge_key)
422
+ ext_edges.append(e)
423
+ if endpoint not in related_symbols_dict and endpoint not in sym_ids:
424
+ endpoint_name = endpoint.split("/")[-1]
425
+ cls_end = self.get_file_classification(endpoint)
426
+ related_symbols_dict[endpoint] = {
427
+ "id": endpoint,
428
+ "name": endpoint_name,
429
+ "qualified_name": endpoint,
430
+ "kind": "file",
431
+ "file_id": endpoint,
432
+ "layer": str(cls_end.layer.value if hasattr(cls_end.layer, "value") else cls_end.layer) if cls_end else "shared",
433
+ "feature": cls_end.feature if cls_end else "external",
434
+ "is_external": True,
435
+ "location": None,
436
+ "signature": None,
437
+ }
438
+
439
+ while frontier and hops < max_traversal_hops:
440
+ next_frontier: Set[str] = set()
441
+ for current_id in frontier:
442
+ for e in self.get_outgoing_edges(current_id):
443
+ edge_key = (e.source_id, e.target_id, str(e.kind))
444
+ if e.target_id in self._symbols:
445
+ other_sym = self._symbols[e.target_id]
446
+ if other_sym.kind in (SymbolKind.FILE, SymbolKind.VARIABLE):
447
+ continue
448
+ other_file = other_sym.location.file_id if other_sym.location else ""
449
+ if other_file and other_file in self._file_symbols:
450
+ if other_file != target_file:
451
+ if edge_key not in collected_cross_edges:
452
+ collected_cross_edges.add(edge_key)
453
+ ext_edges.append(e)
454
+ if other_sym.id not in related_symbols_dict:
455
+ other_cls = self.get_file_classification(other_file)
456
+ related_symbols_dict[other_sym.id] = {
457
+ "id": other_sym.id,
458
+ "name": other_sym.name,
459
+ "qualified_name": other_sym.qualified_name,
460
+ "kind": str(other_sym.kind.value if hasattr(other_sym.kind, "value") else other_sym.kind),
461
+ "file_id": other_file,
462
+ "layer": str(other_cls.layer.value if hasattr(other_cls.layer, "value") else other_cls.layer) if other_cls else "shared",
463
+ "feature": other_cls.feature if other_cls else "external",
464
+ "is_external": True,
465
+ "location": other_sym.location.to_dict() if other_sym.location else None,
466
+ "signature": other_sym.signature,
467
+ }
468
+ if other_sym.id not in visited_symbols:
469
+ visited_symbols.add(other_sym.id)
470
+ next_frontier.add(other_sym.id)
471
+
472
+ for e in self.get_incoming_edges(current_id):
473
+ edge_key = (e.source_id, e.target_id, str(e.kind))
474
+ if e.source_id in self._symbols:
475
+ other_sym = self._symbols[e.source_id]
476
+ if other_sym.kind in (SymbolKind.FILE, SymbolKind.VARIABLE):
477
+ continue
478
+ other_file = other_sym.location.file_id if other_sym.location else ""
479
+ if other_file and other_file in self._file_symbols:
480
+ if other_file != target_file:
481
+ if edge_key not in collected_cross_edges:
482
+ collected_cross_edges.add(edge_key)
483
+ ext_edges.append(e)
484
+ if other_sym.id not in related_symbols_dict:
485
+ other_cls = self.get_file_classification(other_file)
486
+ related_symbols_dict[other_sym.id] = {
487
+ "id": other_sym.id,
488
+ "name": other_sym.name,
489
+ "qualified_name": other_sym.qualified_name,
490
+ "kind": str(other_sym.kind.value if hasattr(other_sym.kind, "value") else other_sym.kind),
491
+ "file_id": other_file,
492
+ "layer": str(other_cls.layer.value if hasattr(other_cls.layer, "value") else other_cls.layer) if other_cls else "shared",
493
+ "feature": other_cls.feature if other_cls else "external",
494
+ "is_external": True,
495
+ "location": other_sym.location.to_dict() if other_sym.location else None,
496
+ "signature": other_sym.signature,
497
+ }
498
+ if other_sym.id not in visited_symbols:
499
+ visited_symbols.add(other_sym.id)
500
+ next_frontier.add(other_sym.id)
501
+ frontier = next_frontier
502
+ hops += 1
503
+
504
+ all_known_ids = sym_ids | set(related_symbols_dict.keys()) | {target_file}
505
+ for e in self._edges:
506
+ edge_key = (e.source_id, e.target_id, str(e.kind))
507
+ if e.source_id in all_known_ids and e.target_id in all_known_ids:
508
+ if edge_key not in collected_cross_edges and (e.source_id not in sym_ids or e.target_id not in sym_ids):
509
+ collected_cross_edges.add(edge_key)
510
+ ext_edges.append(e)
511
+
512
+ return {
513
+ "file_id": target_file,
514
+ "classification": cls.to_dict(),
515
+ "symbols": [s.to_dict() for s in symbols],
516
+ "related_symbols": list(related_symbols_dict.values()),
517
+ "edges": [e.to_dict() for e in intra_edges + ext_edges],
518
+ }
519
+
520
+ ext_id = target_id
521
+ sym_name = ext_id.split("::")[-1].split(".")[-1]
522
+ incoming = [e for e in self._edges if e.target_id == ext_id or e.target_id.endswith("." + sym_name)]
523
+ outgoing = [e for e in self._edges if e.source_id == ext_id or e.source_id.endswith("." + sym_name)]
524
+ ext_edges = incoming + outgoing
525
+ related_symbols = {}
526
+ for e in ext_edges:
527
+ endpoint = e.source_id if e.target_id == ext_id or e.target_id.endswith("." + sym_name) else e.target_id
528
+ if endpoint in self._symbols:
529
+ s = self._symbols[endpoint]
530
+ related_symbols[endpoint] = {
531
+ "id": s.id,
532
+ "name": s.name,
533
+ "qualified_name": s.qualified_name,
534
+ "kind": str(s.kind.value if hasattr(s.kind, "value") else s.kind),
535
+ "file_id": s.location.file_id if s.location else "",
536
+ "location": s.location.to_dict() if s.location else None,
537
+ "signature": s.signature,
538
+ "is_external": False,
539
+ }
540
+ is_class = bool(sym_name and sym_name[0].isupper())
541
+ return {
542
+ "file_id": ext_id,
543
+ "classification": {"layer": "shared", "feature": "external", "module": "external"},
544
+ "is_external": True,
545
+ "symbols": [{
546
+ "id": ext_id,
547
+ "name": sym_name,
548
+ "qualified_name": ext_id,
549
+ "kind": "class" if is_class else "function",
550
+ "signature": f"{sym_name}(...)",
551
+ "is_external": True,
552
+ }],
553
+ "related_symbols": list(related_symbols.values()),
554
+ "edges": [e.to_dict() for e in ext_edges],
555
+ }
556
+
338
557
  def get_subgraph(self, symbol_id: str, depth: int | None = None) -> Tuple[List[Symbol], List[Edge]]:
339
558
  """Extract a sub-graph surrounding a target symbol up to specified depth or all reachable nodes if depth is None.
340
559
  Example:
@@ -398,14 +617,25 @@ class Graph:
398
617
  for file_id in self.get_all_files():
399
618
  classification = self.get_file_classification(file_id)
400
619
  syms = self.get_symbols_by_file(file_id)
620
+ detected_symbols = [s for s in syms if s.kind not in (SymbolKind.FILE, SymbolKind.VARIABLE)]
621
+ clean_path = file_id.replace("\\", "/").lstrip("./")
622
+ parts = [p for p in clean_path.split("/") if p]
623
+ folder = "/".join(parts[:-1]) if len(parts) > 1 else ""
624
+ main_folder = parts[0] if len(parts) > 1 else "root"
625
+ subfolder = parts[-2] if len(parts) > 2 else (parts[0] if len(parts) > 1 else "root")
401
626
  result[file_id] = {
402
627
  "path": file_id,
403
628
  "layer": str(classification.layer),
404
629
  "feature": classification.feature,
405
630
  "module": classification.module,
406
- "symbol_count": len(syms),
407
- "symbols_count": len(syms),
631
+ "folder": folder,
632
+ "main_folder": main_folder,
633
+ "subfolder": subfolder,
634
+ "symbol_count": len(detected_symbols),
635
+ "symbols_count": len(detected_symbols),
636
+ "total_symbols_count": len(syms),
408
637
  "symbol_ids": [s.id for s in syms],
638
+ "detected_symbol_ids": [s.id for s in detected_symbols],
409
639
  }
410
640
  return result
411
641
 
codeui/core/override.py CHANGED
@@ -164,6 +164,7 @@ class OverrideStore:
164
164
  """
165
165
  self._overrides.clear()
166
166
  self._file_contents.clear()
167
+ self._original_file_contents.clear()
167
168
 
168
169
  def list(self) -> Iterable[Override]:
169
170
  """List all current overrides in deterministic order.
codeui/core/resolver.py CHANGED
@@ -20,8 +20,21 @@ class ResolveContext:
20
20
  self._resolve_cache: Dict[Tuple[str, str, str], List[str]] = {}
21
21
  self._known_files: Optional[Set[str]] = None
22
22
  self._search_roots: List[Path] = [self.root_path]
23
+ self.known_extensions: Set[str] = {
24
+ ".py", ".pyi", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".go",
25
+ ".rs", ".java", ".kt", ".kts", ".c", ".cpp", ".cc", ".cxx", ".h", ".hpp",
26
+ ".cs", ".php", ".rb", ".swift", ".scala", ".sc", ".dart", ".json", ".yaml", ".yml"
27
+ }
23
28
  self._load_configs()
24
29
 
30
+ def set_supported_extensions(self, exts: Iterable[str]) -> None:
31
+ """Configure file extensions considered during import resolution.
32
+ Example:
33
+ >>> ctx = ResolveContext(Path("."))
34
+ >>> ctx.set_supported_extensions([".py", ".ts"])
35
+ """
36
+ self.known_extensions = {e.lower() if e.startswith(".") else f".{e.lower()}" for e in exts}
37
+
25
38
  def set_known_files(self, files: Iterable[str]) -> None:
26
39
  """Register known project files to accelerate import resolution without disk checks.
27
40
  Example:
@@ -133,6 +146,9 @@ class ResolveContext:
133
146
  if cache_key in self._resolve_cache:
134
147
  return self._resolve_cache[cache_key]
135
148
 
149
+ all_exts = sorted(list(self.known_extensions))
150
+ index_basenames = ["index", "__init__", "mod", "lib", "main"]
151
+
136
152
  resolved: List[str] = []
137
153
  current_dir = (self.root_path / Path(current_file).parent).resolve() if current_file else self.root_path
138
154
  clean_mod = import_path.replace("module::", "").replace("import:", "").strip()
@@ -145,27 +161,28 @@ class ResolveContext:
145
161
  if target_dir.parent and target_dir.parent != target_dir:
146
162
  target_dir = target_dir.parent
147
163
 
148
- parts = [p for p in rest.split(".") if p]
164
+ parts = [p for p in rest.replace("\\", "/").split("/") if p]
165
+ if not parts and "." in rest:
166
+ parts = [p for p in rest.split(".") if p]
167
+
168
+ candidate_paths: List[Path] = []
149
169
  if not parts:
150
- candidate_paths = [target_dir / "__init__.py", target_dir / "index.ts", target_dir / "index.js"]
170
+ for idx_base in index_basenames:
171
+ for ext in all_exts:
172
+ candidate_paths.append(target_dir / f"{idx_base}{ext}")
151
173
  else:
152
174
  sub_str = "/".join(parts)
153
175
  parent_sub = "/".join(parts[:-1]) if len(parts) > 1 else ""
154
- candidate_paths = [
155
- target_dir / f"{sub_str}.py",
156
- target_dir / f"{sub_str}.ts",
157
- target_dir / f"{sub_str}.js",
158
- target_dir / sub_str / "__init__.py",
159
- target_dir / sub_str / "index.ts",
160
- target_dir / sub_str / "index.js",
161
- ]
162
- if parent_sub:
163
- candidate_paths.extend([
164
- target_dir / f"{parent_sub}.py",
165
- target_dir / f"{parent_sub}.ts",
166
- target_dir / f"{parent_sub}.js",
167
- target_dir / parent_sub / "__init__.py",
168
- ])
176
+ candidate_paths.append(target_dir / sub_str)
177
+ for ext in all_exts:
178
+ candidate_paths.append(target_dir / f"{sub_str}{ext}")
179
+ if parent_sub:
180
+ candidate_paths.append(target_dir / f"{parent_sub}{ext}")
181
+ for idx_base in index_basenames:
182
+ candidate_paths.append(target_dir / sub_str / f"{idx_base}{ext}")
183
+ if parent_sub:
184
+ candidate_paths.append(target_dir / parent_sub / f"{idx_base}{ext}")
185
+
169
186
  for cp in candidate_paths:
170
187
  if self._known_files is not None:
171
188
  try:
@@ -185,9 +202,7 @@ class ResolveContext:
185
202
  if import_path.startswith(alias):
186
203
  rel = import_path[len(alias):].lstrip("/")
187
204
  base = Path(target) / rel
188
- from codeui.lang.registry import LanguageRegistry
189
- extensions = sorted(list(LanguageRegistry().get_supported_extensions() | {".ts", ".tsx", ".js", ".jsx", ".py"}))
190
- for ext in extensions:
205
+ for ext in all_exts:
191
206
  p = base.with_suffix(ext)
192
207
  if self._known_files is not None:
193
208
  try:
@@ -225,9 +240,6 @@ class ResolveContext:
225
240
  if not clean_mod.startswith("."):
226
241
  mod_path_str = clean_mod.replace(".", "/")
227
242
  parts = [p for p in mod_path_str.split("/") if p]
228
- from codeui.lang.registry import LanguageRegistry
229
- code_extensions = sorted(list(LanguageRegistry().get_supported_extensions() | {".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".mjs", ".cjs"}))
230
- index_filenames = ["__init__.py", "index.ts", "index.tsx", "index.js", "index.jsx", "mod.rs", "lib.rs"]
231
243
 
232
244
  if self._known_files is not None:
233
245
  search_root_prefixes = [""] + [f"{r.name}/" for r in self._search_roots if r != self.root_path]
@@ -235,7 +247,7 @@ class ResolveContext:
235
247
  sub_str = "/".join(parts[i:])
236
248
  parent_sub = "/".join(parts[i:-1]) if len(parts[i:]) > 1 else ""
237
249
  for prefix in search_root_prefixes:
238
- for ext in code_extensions:
250
+ for ext in all_exts:
239
251
  cand1 = f"{prefix}{sub_str}{ext}"
240
252
  if cand1 in self._known_files:
241
253
  resolved.append(cand1)
@@ -243,14 +255,15 @@ class ResolveContext:
243
255
  cand2 = f"{prefix}{parent_sub}{ext}"
244
256
  if cand2 in self._known_files:
245
257
  resolved.append(cand2)
246
- for idx_name in index_filenames:
247
- cand3 = f"{prefix}{sub_str}/{idx_name}"
248
- if cand3 in self._known_files:
249
- resolved.append(cand3)
250
- if parent_sub:
251
- cand4 = f"{prefix}{parent_sub}/{idx_name}"
252
- if cand4 in self._known_files:
253
- resolved.append(cand4)
258
+ for idx_base in index_basenames:
259
+ for ext in all_exts:
260
+ cand3 = f"{prefix}{sub_str}/{idx_base}{ext}"
261
+ if cand3 in self._known_files:
262
+ resolved.append(cand3)
263
+ if parent_sub:
264
+ cand4 = f"{prefix}{parent_sub}/{idx_base}{ext}"
265
+ if cand4 in self._known_files:
266
+ resolved.append(cand4)
254
267
  if resolved:
255
268
  break
256
269
  else:
@@ -260,14 +273,15 @@ class ResolveContext:
260
273
  sub_str = "/".join(sub_parts)
261
274
  parent_sub = "/".join(sub_parts[:-1]) if len(sub_parts) > 1 else ""
262
275
  for root in self._search_roots:
263
- for ext in code_extensions:
276
+ for ext in all_exts:
264
277
  candidate_paths.append(root / f"{sub_str}{ext}")
265
278
  if parent_sub:
266
279
  candidate_paths.append(root / f"{parent_sub}{ext}")
267
- for idx_name in index_filenames:
268
- candidate_paths.append(root / sub_str / idx_name)
269
- if parent_sub:
270
- candidate_paths.append(root / parent_sub / idx_name)
280
+ for idx_base in index_basenames:
281
+ for ext in all_exts:
282
+ candidate_paths.append(root / sub_str / f"{idx_base}{ext}")
283
+ if parent_sub:
284
+ candidate_paths.append(root / parent_sub / f"{idx_base}{ext}")
271
285
 
272
286
  for cp in candidate_paths:
273
287
  if cp.exists() and cp.is_file():
codeui/errors.py CHANGED
@@ -96,3 +96,13 @@ class ContributionError(CodeUIError):
96
96
  'Failed to push changes'
97
97
  """
98
98
  pass
99
+
100
+ class ExportError(CodeUIError):
101
+ """Raised when exporting project graph or pages fails.
102
+ Example:
103
+ >>> err = ExportError("Output directory is not writable")
104
+ >>> str(err)
105
+ 'Output directory is not writable'
106
+ """
107
+ pass
108
+
codeui/lang/base.py CHANGED
@@ -1,11 +1,14 @@
1
1
  """Base interface for language AST adapters."""
2
+ from __future__ import annotations
2
3
  import hashlib
3
4
  from abc import ABC, abstractmethod
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
6
- from typing import Any, ClassVar, Iterable, List, Sequence
7
+ from typing import Any, ClassVar, Iterable, List, Sequence, TYPE_CHECKING
7
8
  from codeui.core.ir import Edge, Location, Symbol
8
- from codeui.core.resolver import ResolveContext
9
+
10
+ if TYPE_CHECKING:
11
+ from codeui.core.resolver import ResolveContext
9
12
 
10
13
  @dataclass
11
14
  class ParseResult: