codegraph-engine 2.3.0__py3-none-any.whl → 3.0.0__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 (80) hide show
  1. codegraph/__init__.py +1 -1
  2. codegraph/agent_brain.py +172 -2
  3. codegraph/agent_capabilities.py +320 -3
  4. codegraph/agent_rules.py +1 -1
  5. codegraph/api_drift.py +455 -0
  6. codegraph/async_queue/__init__.py +50 -0
  7. codegraph/async_queue/detectors/__init__.py +48 -0
  8. codegraph/async_queue/detectors/celery.py +160 -0
  9. codegraph/async_queue/detectors/kafka.py +168 -0
  10. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  11. codegraph/async_queue/detectors/redis.py +130 -0
  12. codegraph/async_queue/detectors/sqs.py +135 -0
  13. codegraph/async_queue/models.py +161 -0
  14. codegraph/async_queue/otel_models.py +74 -0
  15. codegraph/async_queue/otel_parser.py +337 -0
  16. codegraph/async_queue/reconciler.py +427 -0
  17. codegraph/async_queue/scanner.py +291 -0
  18. codegraph/async_queue/traversal.py +447 -0
  19. codegraph/change_impact.py +320 -110
  20. codegraph/cli.py +776 -25
  21. codegraph/cli_output.py +17 -2
  22. codegraph/context.py +38 -11
  23. codegraph/context_freshness.py +2 -0
  24. codegraph/database/__init__.py +14 -0
  25. codegraph/database/extractor.py +712 -17
  26. codegraph/database/interrogation.py +388 -2
  27. codegraph/database/schema_drift.py +862 -0
  28. codegraph/epistemic.py +7 -0
  29. codegraph/evidence_contract.py +21 -0
  30. codegraph/frameworks.py +171 -6
  31. codegraph/frameworks_nestjs.py +502 -0
  32. codegraph/frameworks_nextjs.py +337 -0
  33. codegraph/frameworks_react.py +457 -0
  34. codegraph/freshness.py +2 -0
  35. codegraph/frontend_html.py +109 -0
  36. codegraph/git.py +2 -0
  37. codegraph/git_state.py +2 -0
  38. codegraph/indexing/__init__.py +7 -1
  39. codegraph/indexing/indexer.py +291 -38
  40. codegraph/indexing/models.py +6 -0
  41. codegraph/indexing/parser.py +239 -0
  42. codegraph/indexing/pool.py +136 -0
  43. codegraph/indexing/scanner.py +6 -0
  44. codegraph/indexing/templates.py +418 -0
  45. codegraph/interrogation.py +128 -20
  46. codegraph/mcp/server.py +190 -28
  47. codegraph/models.py +7 -0
  48. codegraph/monorepo.py +3 -0
  49. codegraph/optimizer.py +42 -5
  50. codegraph/process_lifecycle.py +31 -13
  51. codegraph/refactor/__init__.py +36 -0
  52. codegraph/refactor/models.py +114 -0
  53. codegraph/refactor/renamer.py +844 -0
  54. codegraph/refactor/transactions.py +403 -0
  55. codegraph/resolver.py +1202 -159
  56. codegraph/retrieval_policy.py +6 -0
  57. codegraph/route_topology.py +172 -0
  58. codegraph/runtime/__init__.py +19 -0
  59. codegraph/runtime/collector.py +155 -0
  60. codegraph/runtime/db_watcher.py +191 -0
  61. codegraph/runtime/ingestor.py +30 -10
  62. codegraph/runtime/port_inspector.py +304 -0
  63. codegraph/runtime/runner.py +155 -6
  64. codegraph/runtime/trace_linker.py +175 -0
  65. codegraph/search/hybrid.py +26 -0
  66. codegraph/semantic_decorators.py +34 -3
  67. codegraph/structural_diff.py +2 -0
  68. codegraph/styles.py +144 -0
  69. codegraph/symbol_history.py +2 -0
  70. codegraph/tsconfig.py +236 -0
  71. codegraph/ui/__init__.py +6 -0
  72. codegraph/ui/server.py +715 -0
  73. codegraph/watcher.py +323 -0
  74. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/METADATA +150 -38
  75. codegraph_engine-3.0.0.dist-info/RECORD +129 -0
  76. codegraph_engine-2.3.0.dist-info/RECORD +0 -94
  77. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/WHEEL +0 -0
  78. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/entry_points.txt +0 -0
  79. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/licenses/LICENSE +0 -0
  80. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/top_level.txt +0 -0
@@ -623,6 +623,11 @@ def search_code(
623
623
 
624
624
  config_exts = {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf"}
625
625
  filtered: list[dict[str, object]] = []
626
+ seen_file_lines: set[tuple[str, int]] = set()
627
+ files_with_exact_matches: set[str] = set()
628
+
629
+ query_terms = _safe_fts5_terms(clean_query)
630
+ query_terms_lower = [t.lower() for t in query_terms]
626
631
 
627
632
  for item in raw_hits:
628
633
  rel_path = item.path or item.file
@@ -659,6 +664,27 @@ def search_code(
659
664
  d["match_type"] = "case_insensitive"
660
665
  else:
661
666
  d["match_type"] = "token"
667
+
668
+ hit_line = item.line
669
+ file_line_key = (rel_path, hit_line)
670
+ if file_line_key in seen_file_lines:
671
+ continue
672
+
673
+ # If multi-word query and this hit is only a token match:
674
+ # verify that all query terms appear in the snippet or matched text
675
+ if len(query_terms_lower) >= 2 and d["match_type"] == "token":
676
+ haystack = (str(d.get("snippet", "")) + " " + str(d.get("matched_text", ""))).lower()
677
+ if not all(t in haystack for t in query_terms_lower):
678
+ continue
679
+
680
+ # If a file already had an exact/case-insensitive match, discard subsequent loose token matches
681
+ if rel_path in files_with_exact_matches and d["match_type"] == "token":
682
+ continue
683
+
684
+ if d["match_type"] in ("exact", "case_insensitive"):
685
+ files_with_exact_matches.add(rel_path)
686
+
687
+ seen_file_lines.add(file_line_key)
662
688
  filtered.append(d)
663
689
  if len(filtered) >= effective_limit:
664
690
  break
@@ -66,6 +66,31 @@ _RECOGNIZED_DECORATOR_SPECS: tuple[SemanticDecoratorSpec, ...] = (
66
66
  semantic_role="PROVIDES",
67
67
  patterns=("provide", "di.provide", "provider", "singleton", "di.singleton"),
68
68
  ),
69
+ SemanticDecoratorSpec(
70
+ framework="langchain",
71
+ semantic_role="TOOL_HANDLER",
72
+ patterns=("tool", "langchain.tools.tool", "langchain_core.tools.tool", "agents.tool"),
73
+ ),
74
+ SemanticDecoratorSpec(
75
+ framework="llama_index",
76
+ semantic_role="TOOL_HANDLER",
77
+ patterns=("function_tool", "llama_index.core.tools.function_tool"),
78
+ ),
79
+ SemanticDecoratorSpec(
80
+ framework="ray",
81
+ semantic_role="TASK_HANDLER",
82
+ patterns=("ray.remote", "remote"),
83
+ ),
84
+ SemanticDecoratorSpec(
85
+ framework="prefect",
86
+ semantic_role="TASK_HANDLER",
87
+ patterns=("prefect.task", "prefect.flow", "task", "flow"),
88
+ ),
89
+ SemanticDecoratorSpec(
90
+ framework="zenml",
91
+ semantic_role="TASK_HANDLER",
92
+ patterns=("step", "pipeline"),
93
+ ),
69
94
  )
70
95
 
71
96
 
@@ -132,12 +157,12 @@ def analyze_symbol_decorators(
132
157
  if "." in raw_expr:
133
158
  base = raw_expr.split(".")[0]
134
159
  # Verify base is imported or standard framework name
135
- if base in import_map or base in ("app", "celery", "click", "django", "typer", "di", "inject"):
160
+ if base in import_map or base in ("app", "celery", "click", "django", "typer", "di", "inject", "ray", "langchain", "llama_index", "prefect", "zenml"):
136
161
  grounded_name = raw_expr
137
162
  matched_spec = spec
138
163
  break
139
164
  else:
140
- # Bare name (e.g. shared_task, receiver, command, inject, provide, singleton)
165
+ # Bare name (e.g. shared_task, receiver, command, inject, provide, singleton, tool)
141
166
  # Verify it was imported from the framework or known pattern
142
167
  if raw_expr in import_map:
143
168
  imp_mod, imp_orig = import_map[raw_expr]
@@ -146,7 +171,7 @@ def analyze_symbol_decorators(
146
171
  grounded_name = f"{imp_mod}.{orig_or_raw}"
147
172
  matched_spec = spec
148
173
  break
149
- elif raw_expr in ("inject", "provide", "singleton"):
174
+ elif raw_expr in ("inject", "provide", "singleton", "receiver", "tool", "function_tool", "remote", "step", "flow"):
150
175
  grounded_name = raw_expr
151
176
  matched_spec = spec
152
177
  break
@@ -242,6 +267,12 @@ def analyze_symbol_decorators(
242
267
  dyn_reason = "dynamic_event_name"
243
268
  if event_names:
244
269
  metadata["events"] = event_names
270
+ for kw in kw_nodes:
271
+ if kw.arg == "sender":
272
+ if isinstance(kw.value, (ast.Name, ast.Attribute)) and hasattr(ast, "unparse"):
273
+ metadata["sender"] = ast.unparse(kw.value)
274
+ elif isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
275
+ metadata["sender"] = kw.value.value
245
276
 
246
277
  elif matched_spec.semantic_role == "COMMAND_HANDLER":
247
278
  # Extract command name
@@ -152,6 +152,8 @@ def _run_git(repository: Path, *args: str, timeout: int = 15) -> str | None:
152
152
  cwd=str(repository),
153
153
  capture_output=True,
154
154
  text=True,
155
+ encoding="utf-8",
156
+ errors="replace",
155
157
  timeout=timeout,
156
158
  )
157
159
  return res.stdout if res.returncode == 0 else None
codegraph/styles.py ADDED
@@ -0,0 +1,144 @@
1
+ """CSS, SCSS, and CSS Modules Architecture and Selector Analyzer (Pillar 4).
2
+
3
+ Extracts deterministic facts from stylesheets:
4
+ - CSS class selectors (.btn-primary, .invoice-card, .header_title)
5
+ - CSS custom properties/variables (--primary-color, --spacing-md)
6
+ - CSS module boundaries
7
+ - Emits Symbol entities for style search and cross-component impact analysis.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+
14
+ from codegraph.indexing.models import Symbol
15
+
16
+ # CSS Class Selector pattern: .className { or .className:hover or .className.active
17
+ # Excludes decimals like .5s, .75em, .0
18
+ _CSS_CLASS_RE = re.compile(
19
+ r"""(?<![\w\.\-])\.([A-Za-z_][A-Za-z0-9_-]*)(?=[ \t\r\n\.:,\{>+~])"""
20
+ )
21
+
22
+ # CSS Custom Property / Variable declaration: --primary-color: #0070f3;
23
+ _CSS_VAR_RE = re.compile(
24
+ r"""(--[A-Za-z_][A-Za-z0-9_-]*)\s*:"""
25
+ )
26
+
27
+ # Hex color detector to ignore #fff, #123456 as ID selectors
28
+ _HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$")
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CssClassDef:
33
+ name: str
34
+ file_path: str
35
+ line: int
36
+ is_module: bool
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class CssVariableDef:
41
+ name: str
42
+ file_path: str
43
+ line: int
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class StyleFileResult:
48
+ classes: tuple[CssClassDef, ...] = ()
49
+ variables: tuple[CssVariableDef, ...] = ()
50
+ symbols: tuple[Symbol, ...] = ()
51
+
52
+
53
+ def is_style_file(file_path: str) -> bool:
54
+ """Check if file is a CSS, SCSS, Sass, or Less stylesheet."""
55
+ lower = file_path.lower()
56
+ return lower.endswith((".css", ".scss", ".sass", ".less"))
57
+
58
+
59
+ def parse_style_file(content: str, file_path: str) -> StyleFileResult:
60
+ """Extract CSS classes, custom properties, and symbols from stylesheet."""
61
+ if not is_style_file(file_path):
62
+ return StyleFileResult()
63
+
64
+ is_module = ".module." in file_path.lower()
65
+ classes: list[CssClassDef] = []
66
+ variables: list[CssVariableDef] = []
67
+ symbols: list[Symbol] = []
68
+
69
+ seen_classes: set[str] = set()
70
+ seen_vars: set[str] = set()
71
+
72
+ clean_file = file_path.replace("\\", "/").lstrip("./")
73
+
74
+ for idx, line in enumerate(content.splitlines()):
75
+ lineno = idx + 1
76
+ stripped = line.strip()
77
+
78
+ # Skip comment-only lines
79
+ if stripped.startswith("/*") or stripped.startswith("*") or stripped.startswith("//"):
80
+ continue
81
+
82
+ # Extract CSS variables
83
+ for m_var in _CSS_VAR_RE.finditer(line):
84
+ v_name = m_var.group(1)
85
+ if v_name not in seen_vars:
86
+ seen_vars.add(v_name)
87
+ variables.append(CssVariableDef(name=v_name, file_path=clean_file, line=lineno))
88
+ canon_id = f"{clean_file}::{v_name}"
89
+ symbols.append(
90
+ Symbol(
91
+ id=canon_id,
92
+ canonical_id=canon_id,
93
+ name=v_name,
94
+ qualified_name=v_name,
95
+ kind="style_var",
96
+ language="css",
97
+ module=clean_file,
98
+ path=clean_file,
99
+ file_path=clean_file,
100
+ scope="",
101
+ start_line=lineno,
102
+ end_line=lineno,
103
+ )
104
+ )
105
+
106
+ # Extract CSS classes
107
+ for m_cls in _CSS_CLASS_RE.finditer(line):
108
+ c_name = m_cls.group(1)
109
+ # Avoid pseudo-elements and animations
110
+ if c_name in ("hover", "focus", "active", "visited", "disabled", "first-child", "last-child"):
111
+ continue
112
+ if c_name not in seen_classes:
113
+ seen_classes.add(c_name)
114
+ classes.append(
115
+ CssClassDef(
116
+ name=c_name,
117
+ file_path=clean_file,
118
+ line=lineno,
119
+ is_module=is_module,
120
+ )
121
+ )
122
+ canon_id = f"{clean_file}::{c_name}"
123
+ symbols.append(
124
+ Symbol(
125
+ id=canon_id,
126
+ canonical_id=canon_id,
127
+ name=f".{c_name}",
128
+ qualified_name=f".{c_name}",
129
+ kind="style_class",
130
+ language="css",
131
+ module=clean_file,
132
+ path=clean_file,
133
+ file_path=clean_file,
134
+ scope="",
135
+ start_line=lineno,
136
+ end_line=lineno,
137
+ )
138
+ )
139
+
140
+ return StyleFileResult(
141
+ classes=tuple(classes),
142
+ variables=tuple(variables),
143
+ symbols=tuple(symbols),
144
+ )
@@ -87,6 +87,8 @@ def _run_git(repository: Path, *args: str, timeout: int = 15) -> str | None:
87
87
  cwd=str(repository),
88
88
  capture_output=True,
89
89
  text=True,
90
+ encoding="utf-8",
91
+ errors="replace",
90
92
  timeout=timeout,
91
93
  )
92
94
  return res.stdout if res.returncode == 0 else None
codegraph/tsconfig.py ADDED
@@ -0,0 +1,236 @@
1
+ """TypeScript and JavaScript Path Alias Resolution (tsconfig.json / jsconfig.json).
2
+
3
+ Provides deterministic resolution of compilerOptions.paths and baseUrl across
4
+ monorepos, Next.js, Vite, and React projects without external dependencies.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import posixpath
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+
15
+ def strip_json_comments(text: str) -> str:
16
+ """Strip single-line and multi-line comments and trailing commas from tsconfig/jsconfig JSON."""
17
+ # Remove block comments /* ... */
18
+ text = re.sub(r"/\*[\s\S]*?\*/", "", text)
19
+ # Remove line comments // ... (not inside quotes)
20
+ lines: list[str] = []
21
+ for line in text.splitlines():
22
+ # Match // not preceded by a quote mark
23
+ cleaned_line = re.sub(r'(?<![:"\'\\])//.*$', '', line)
24
+ lines.append(cleaned_line)
25
+ cleaned = "\n".join(lines)
26
+ # Remove trailing commas before } or ]
27
+ cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned)
28
+ return cleaned
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class TsConfig:
33
+ config_path: str
34
+ base_dir: str
35
+ base_url: str
36
+ paths: dict[str, tuple[str, ...]] = field(default_factory=dict)
37
+
38
+
39
+ class TsConfigResolver:
40
+ """Manages tsconfig.json and jsconfig.json path mappings across workspaces."""
41
+
42
+ def __init__(self, configs: list[TsConfig] | None = None) -> None:
43
+ self.configs: list[TsConfig] = configs or []
44
+
45
+ @classmethod
46
+ def load_from_repo(cls, repo_root: Path) -> TsConfigResolver:
47
+ """Scan workspace for tsconfig.json, jsconfig.json, and vite.config.* files."""
48
+ configs: list[TsConfig] = []
49
+ candidates = (
50
+ list(repo_root.glob("**/tsconfig*.json"))
51
+ + list(repo_root.glob("**/jsconfig*.json"))
52
+ + list(repo_root.glob("**/vite.config.*"))
53
+ )
54
+
55
+ for c_path in candidates:
56
+ # Skip node_modules or build outputs
57
+ parts = c_path.parts
58
+ if any(p in ("node_modules", ".git", "dist", "build", ".next", ".cache") for p in parts):
59
+ continue
60
+ if c_path.name.startswith("vite.config."):
61
+ cfg = cls._parse_vite_config(c_path, repo_root)
62
+ else:
63
+ cfg = cls._parse_single_config(c_path, repo_root)
64
+ if cfg:
65
+ configs.append(cfg)
66
+
67
+ # Sort configs: deepest path first for nearest-config resolution
68
+ configs.sort(key=lambda c: len(c.base_dir), reverse=True)
69
+ return cls(configs)
70
+
71
+ @staticmethod
72
+ def _parse_vite_config(file_path: Path, repo_root: Path) -> TsConfig | None:
73
+ try:
74
+ content = file_path.read_text(encoding="utf-8", errors="replace")
75
+ parsed_paths: dict[str, tuple[str, ...]] = {}
76
+
77
+ # 1. Object alias: alias: { '@': path.resolve(__dirname, './src'), ... }
78
+ alias_match = re.search(r"alias\s*:\s*\{([^}]+)\}", content)
79
+ if alias_match:
80
+ entries = re.findall(
81
+ r"""['"]([^'"]+)['"]\s*:\s*(?:(?:path\.)?resolve\([^)]*['"]([^'"]+)['"]\)|['"]([^'"]+)['"])""",
82
+ alias_match.group(1),
83
+ )
84
+ for key, target1, target2 in entries:
85
+ raw_target = (target1 or target2 or "").strip().replace("\\", "/").lstrip("./")
86
+ if raw_target:
87
+ clean_key = key.rstrip("/")
88
+ parsed_paths[f"{clean_key}/*"] = (f"{raw_target}/*",)
89
+ parsed_paths[clean_key] = (raw_target,)
90
+
91
+ # 2. Array alias: alias: [ { find: '@', replacement: path.resolve(__dirname, './src') } ]
92
+ array_entries = re.findall(
93
+ r"""find\s*:\s*['"]([^'"]+)['"][\s\S]*?replacement\s*:\s*(?:(?:path\.)?resolve\([^)]*['"]([^'"]+)['"]\)|['"]([^'"]+)['"])""",
94
+ content,
95
+ )
96
+ for key, target1, target2 in array_entries:
97
+ raw_target = (target1 or target2 or "").strip().replace("\\", "/").lstrip("./")
98
+ if raw_target:
99
+ clean_key = key.rstrip("/")
100
+ parsed_paths[f"{clean_key}/*"] = (f"{raw_target}/*",)
101
+ parsed_paths[clean_key] = (raw_target,)
102
+
103
+ if not parsed_paths:
104
+ return None
105
+
106
+ base_dir_path = file_path.parent
107
+ rel_base_dir = posixpath.normpath(base_dir_path.relative_to(repo_root).as_posix())
108
+ if rel_base_dir == ".":
109
+ rel_base_dir = ""
110
+
111
+ return TsConfig(
112
+ config_path=posixpath.normpath(file_path.relative_to(repo_root).as_posix()),
113
+ base_dir=rel_base_dir,
114
+ base_url=".",
115
+ paths=parsed_paths,
116
+ )
117
+ except Exception:
118
+ return None
119
+
120
+ @staticmethod
121
+ def _parse_single_config(file_path: Path, repo_root: Path) -> TsConfig | None:
122
+ try:
123
+ raw = file_path.read_text(encoding="utf-8", errors="replace")
124
+ cleaned = strip_json_comments(raw)
125
+ data = json.loads(cleaned)
126
+ compiler_opts = data.get("compilerOptions", {})
127
+
128
+ base_dir_path = file_path.parent
129
+ rel_base_dir = posixpath.normpath(base_dir_path.relative_to(repo_root).as_posix())
130
+ if rel_base_dir == ".":
131
+ rel_base_dir = ""
132
+
133
+ base_url = compiler_opts.get("baseUrl", ".")
134
+ raw_paths = compiler_opts.get("paths", {})
135
+
136
+ parsed_paths: dict[str, tuple[str, ...]] = {}
137
+ for pattern, targets in raw_paths.items():
138
+ if isinstance(targets, list):
139
+ parsed_paths[pattern] = tuple(str(t) for t in targets)
140
+ elif isinstance(targets, str):
141
+ parsed_paths[pattern] = (targets,)
142
+
143
+ if not parsed_paths and base_url in (".", ""):
144
+ return None
145
+
146
+ return TsConfig(
147
+ config_path=posixpath.normpath(file_path.relative_to(repo_root).as_posix()),
148
+ base_dir=rel_base_dir,
149
+ base_url=base_url,
150
+ paths=parsed_paths,
151
+ )
152
+ except Exception:
153
+ return None
154
+
155
+ def resolve_alias(
156
+ self,
157
+ imported_module: str,
158
+ source_file: str,
159
+ known_files: set[str],
160
+ ) -> str | None:
161
+ """Resolve an aliased module (e.g. '@/components/Button') to a file path in known_files."""
162
+ if not imported_module or imported_module.startswith("."):
163
+ return None
164
+
165
+ clean_src = source_file.replace("\\", "/").lstrip("./")
166
+
167
+ # Pick matching config: deepest base_dir matching source_file, or fallback to root config
168
+ chosen_config: TsConfig | None = None
169
+ for cfg in self.configs:
170
+ if not cfg.base_dir or clean_src.startswith(f"{cfg.base_dir}/"):
171
+ chosen_config = cfg
172
+ break
173
+ if not chosen_config and self.configs:
174
+ chosen_config = self.configs[-1]
175
+
176
+ if not chosen_config:
177
+ return None
178
+
179
+ # Check paths mappings
180
+ for pattern, targets in chosen_config.paths.items():
181
+ if "*" in pattern:
182
+ star_idx = pattern.index("*")
183
+ prefix = pattern[:star_idx]
184
+ suffix = pattern[star_idx + 1:]
185
+ if imported_module.startswith(prefix) and (not suffix or imported_module.endswith(suffix)):
186
+ wildcard = imported_module[len(prefix):len(imported_module) - len(suffix) if suffix else None]
187
+ for target_tmpl in targets:
188
+ target = target_tmpl.replace("*", wildcard)
189
+ resolved = self._test_target(
190
+ chosen_config.base_dir, chosen_config.base_url, target, known_files
191
+ )
192
+ if resolved:
193
+ return resolved
194
+ else:
195
+ # Exact match pattern (e.g. "@lib": ["src/lib"])
196
+ if imported_module == pattern:
197
+ for target in targets:
198
+ resolved = self._test_target(
199
+ chosen_config.base_dir, chosen_config.base_url, target, known_files
200
+ )
201
+ if resolved:
202
+ return resolved
203
+
204
+ # Fallback to baseUrl-relative imports if baseUrl is configured
205
+ if chosen_config.base_url and chosen_config.base_url not in (".", ""):
206
+ resolved = self._test_target(
207
+ chosen_config.base_dir, chosen_config.base_url, imported_module, known_files
208
+ )
209
+ if resolved:
210
+ return resolved
211
+
212
+ return None
213
+
214
+ @staticmethod
215
+ def _test_target(
216
+ base_dir: str,
217
+ base_url: str,
218
+ target_path: str,
219
+ known_files: set[str],
220
+ ) -> str | None:
221
+ """Check candidate target path against known repository files with extensions and index files."""
222
+ # Join base_dir, base_url, and target_path in posix style
223
+ parts = [p for p in (base_dir, base_url, target_path) if p and p not in (".", "")]
224
+ base = posixpath.normpath("/".join(parts)).lstrip("./")
225
+
226
+ candidates = [base]
227
+ for ext in (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css", ".scss"):
228
+ candidates.append(f"{base}{ext}")
229
+ for idx in ("index.ts", "index.tsx", "index.js", "index.jsx"):
230
+ candidates.append(posixpath.join(base, idx))
231
+
232
+ for cand in candidates:
233
+ cand_norm = cand.lstrip("./")
234
+ if cand_norm in known_files:
235
+ return cand_norm
236
+ return None
@@ -0,0 +1,6 @@
1
+ """CodeGraph UI module."""
2
+ from __future__ import annotations
3
+
4
+ from .server import start_ui_server
5
+
6
+ __all__ = ["start_ui_server"]