codegraph-engine 2.2.1__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.
- codegraph/__init__.py +1 -1
- codegraph/agent_brain.py +172 -2
- codegraph/agent_capabilities.py +564 -6
- codegraph/agent_rules.py +1 -1
- codegraph/api_drift.py +455 -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/branch_comparison.py +197 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +992 -3
- codegraph/cli_output.py +17 -2
- codegraph/context.py +38 -11
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +14 -0
- codegraph/database/extractor.py +712 -17
- codegraph/database/interrogation.py +388 -2
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +7 -0
- codegraph/evidence_contract.py +21 -0
- codegraph/frameworks.py +171 -6
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +2 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +2 -0
- codegraph/git_state.py +545 -0
- codegraph/indexing/__init__.py +7 -1
- codegraph/indexing/indexer.py +291 -38
- codegraph/indexing/models.py +6 -0
- codegraph/indexing/parser.py +239 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +6 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/interrogation.py +128 -20
- codegraph/mcp/server.py +258 -27
- codegraph/models.py +7 -0
- codegraph/monorepo.py +3 -0
- codegraph/optimizer.py +42 -5
- codegraph/process_lifecycle.py +31 -13
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +403 -0
- codegraph/resolver.py +1202 -159
- codegraph/retrieval_policy.py +6 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +19 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +30 -10
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/runner.py +155 -6
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/hybrid.py +26 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +34 -3
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +715 -0
- codegraph/watcher.py +323 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/METADATA +257 -39
- codegraph_engine-3.0.0.dist-info/RECORD +129 -0
- codegraph_engine-2.2.1.dist-info/RECORD +0 -87
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/WHEEL +0 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.2.1.dist-info → codegraph_engine-3.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Deterministic Symbol History Engine.
|
|
2
|
+
|
|
3
|
+
Traces the evolutionary history of a symbol across Git commits:
|
|
4
|
+
- Symbol introduced / added
|
|
5
|
+
- Symbol modified (signature, body, docstring)
|
|
6
|
+
- Symbol renamed (tracked via AST structure & body hashes)
|
|
7
|
+
- Symbol moved across files (tracked via Git renames and structural equality)
|
|
8
|
+
- Symbol deleted
|
|
9
|
+
|
|
10
|
+
Epistemic states:
|
|
11
|
+
- FACT: Exact body hash match, canonical ID continuity, or Git rename (-M) metadata
|
|
12
|
+
- POSSIBLE: Structural or signature similarity without identical body hash
|
|
13
|
+
- AMBIGUOUS: Multiple candidate replacements detected
|
|
14
|
+
- UNKNOWN: Disappeared with no identifiable successor
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import sqlite3
|
|
20
|
+
import subprocess
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from codegraph.git import recent_commits
|
|
26
|
+
from codegraph.indexing.parser import parse
|
|
27
|
+
from codegraph.indexing.scanner import LANGUAGES
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SymbolHistoryEvent:
|
|
32
|
+
commit_sha: str
|
|
33
|
+
date: str
|
|
34
|
+
author: str
|
|
35
|
+
message: str
|
|
36
|
+
event_type: str # "INTRODUCED" | "MODIFIED" | "RENAMED" | "MOVED" | "DELETED"
|
|
37
|
+
path: str
|
|
38
|
+
old_path: str | None = None
|
|
39
|
+
old_name: str | None = None
|
|
40
|
+
new_name: str | None = None
|
|
41
|
+
confidence: str = "FACT" # "FACT" | "POSSIBLE" | "AMBIGUOUS" | "UNKNOWN"
|
|
42
|
+
evidence: str = ""
|
|
43
|
+
|
|
44
|
+
def as_dict(self) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"commit_sha": self.commit_sha,
|
|
47
|
+
"date": self.date,
|
|
48
|
+
"author": self.author,
|
|
49
|
+
"message": self.message,
|
|
50
|
+
"event_type": self.event_type,
|
|
51
|
+
"path": self.path,
|
|
52
|
+
"old_path": self.old_path,
|
|
53
|
+
"old_name": self.old_name,
|
|
54
|
+
"new_name": self.new_name,
|
|
55
|
+
"confidence": self.confidence,
|
|
56
|
+
"evidence": self.evidence,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class SymbolHistoryReport:
|
|
62
|
+
symbol: str
|
|
63
|
+
current_path: str | None
|
|
64
|
+
events: list[SymbolHistoryEvent] = field(default_factory=list)
|
|
65
|
+
total_commits_evaluated: int = 0
|
|
66
|
+
status: str = "SUCCESS" # "SUCCESS" | "NOT_FOUND" | "AMBIGUOUS"
|
|
67
|
+
detail: str = ""
|
|
68
|
+
|
|
69
|
+
def as_dict(self) -> dict[str, Any]:
|
|
70
|
+
return {
|
|
71
|
+
"symbol": self.symbol,
|
|
72
|
+
"current_path": self.current_path,
|
|
73
|
+
"events": [e.as_dict() for e in self.events],
|
|
74
|
+
"total_commits_evaluated": self.total_commits_evaluated,
|
|
75
|
+
"status": self.status,
|
|
76
|
+
"detail": self.detail,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run_git(repository: Path, *args: str, timeout: int = 15) -> str | None:
|
|
81
|
+
for arg in args:
|
|
82
|
+
if any(c in arg for c in (";", "|", "&", "`", "$", "\n", "\r")):
|
|
83
|
+
return None
|
|
84
|
+
try:
|
|
85
|
+
res = subprocess.run(
|
|
86
|
+
["git", *args],
|
|
87
|
+
cwd=str(repository),
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
encoding="utf-8",
|
|
91
|
+
errors="replace",
|
|
92
|
+
timeout=timeout,
|
|
93
|
+
)
|
|
94
|
+
return res.stdout if res.returncode == 0 else None
|
|
95
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_BODY_HASH_CACHE: dict[tuple[int, str, str, str], tuple[str | None, int | None]] = {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _get_symbol_body_hash(content: str, lang: str, file_path: str, symbol_name: str) -> tuple[str | None, int | None]:
|
|
103
|
+
"""Parse content and compute content hash for symbol body with caching."""
|
|
104
|
+
if not content:
|
|
105
|
+
return None, None
|
|
106
|
+
c_hash = hash(content)
|
|
107
|
+
cache_key = (c_hash, lang, file_path, symbol_name)
|
|
108
|
+
if cache_key in _BODY_HASH_CACHE:
|
|
109
|
+
return _BODY_HASH_CACHE[cache_key]
|
|
110
|
+
try:
|
|
111
|
+
res = parse(content, lang, file_path)
|
|
112
|
+
for s in res.symbols:
|
|
113
|
+
if s.name == symbol_name:
|
|
114
|
+
lines = content.splitlines()
|
|
115
|
+
snippet = "\n".join(lines[max(0, s.start_line - 1) : s.end_line]).strip()
|
|
116
|
+
b_hash = hashlib.sha256(snippet.encode("utf-8", errors="replace")).hexdigest()[:16]
|
|
117
|
+
result = (b_hash, s.start_line)
|
|
118
|
+
if len(_BODY_HASH_CACHE) > 2000:
|
|
119
|
+
_BODY_HASH_CACHE.clear()
|
|
120
|
+
_BODY_HASH_CACHE[cache_key] = result
|
|
121
|
+
return result
|
|
122
|
+
_BODY_HASH_CACHE[cache_key] = (None, None)
|
|
123
|
+
return None, None
|
|
124
|
+
except Exception:
|
|
125
|
+
return None, None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def trace_symbol_history(
|
|
129
|
+
repository: Path,
|
|
130
|
+
symbol: str,
|
|
131
|
+
path: str | None = None,
|
|
132
|
+
con: sqlite3.Connection | None = None,
|
|
133
|
+
max_commits: int = 15,
|
|
134
|
+
) -> SymbolHistoryReport:
|
|
135
|
+
"""Trace the history of a symbol across Git commits deterministically."""
|
|
136
|
+
from codegraph.structural_diff import _get_file_content_at_ref
|
|
137
|
+
|
|
138
|
+
# 1. Resolve path if not provided
|
|
139
|
+
resolved_path = path
|
|
140
|
+
if not resolved_path and con is not None:
|
|
141
|
+
try:
|
|
142
|
+
row = con.execute("SELECT path FROM symbols WHERE name=? LIMIT 1", (symbol,)).fetchone()
|
|
143
|
+
if row:
|
|
144
|
+
resolved_path = str(row[0])
|
|
145
|
+
except (sqlite3.OperationalError, sqlite3.DatabaseError):
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
# 2. Get commits affecting the file or symbol
|
|
149
|
+
commits = recent_commits(repository, n=max_commits * 2, path=resolved_path)
|
|
150
|
+
if not commits:
|
|
151
|
+
return SymbolHistoryReport(
|
|
152
|
+
symbol=symbol,
|
|
153
|
+
current_path=resolved_path,
|
|
154
|
+
events=[],
|
|
155
|
+
total_commits_evaluated=0,
|
|
156
|
+
status="NOT_FOUND",
|
|
157
|
+
detail=f"No Git commits found for symbol '{symbol}' or path '{resolved_path}'.",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
events: list[SymbolHistoryEvent] = []
|
|
161
|
+
current_sym_name = symbol
|
|
162
|
+
current_sym_path = resolved_path or ""
|
|
163
|
+
|
|
164
|
+
prev_parent_content: str | None = None
|
|
165
|
+
prev_parent_hash: str | None = None
|
|
166
|
+
prev_parent_line: int | None = None
|
|
167
|
+
prev_parent_path: str | None = None
|
|
168
|
+
|
|
169
|
+
for c in commits:
|
|
170
|
+
if len(events) >= max_commits:
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
ext = Path(current_sym_path).suffix.lower() if current_sym_path else ".py"
|
|
174
|
+
lang = LANGUAGES.get(ext, "python")
|
|
175
|
+
|
|
176
|
+
# Fast path: check if curr_content was already loaded as parent in previous iteration
|
|
177
|
+
curr_content: str | None = None
|
|
178
|
+
curr_hash: str | None = None
|
|
179
|
+
curr_line: int | None = None
|
|
180
|
+
|
|
181
|
+
if prev_parent_content is not None and prev_parent_path == current_sym_path:
|
|
182
|
+
curr_content = prev_parent_content
|
|
183
|
+
curr_hash, curr_line = prev_parent_hash, prev_parent_line
|
|
184
|
+
else:
|
|
185
|
+
curr_content = _get_file_content_at_ref(repository, c.sha, current_sym_path) if current_sym_path else None
|
|
186
|
+
curr_hash, curr_line = _get_symbol_body_hash(curr_content or "", lang, current_sym_path, current_sym_name) if curr_content else (None, None)
|
|
187
|
+
|
|
188
|
+
# Get file content at parent commit c.sha~1
|
|
189
|
+
parent_content = _get_file_content_at_ref(repository, f"{c.sha}~1", current_sym_path) if current_sym_path else None
|
|
190
|
+
parent_hash, parent_line = _get_symbol_body_hash(parent_content or "", lang, current_sym_path, current_sym_name) if parent_content else (None, None)
|
|
191
|
+
|
|
192
|
+
# Store for next iteration
|
|
193
|
+
prev_parent_content = parent_content
|
|
194
|
+
prev_parent_hash = parent_hash
|
|
195
|
+
prev_parent_line = parent_line
|
|
196
|
+
prev_parent_path = current_sym_path
|
|
197
|
+
|
|
198
|
+
if curr_hash is not None and parent_hash is not None:
|
|
199
|
+
if curr_hash != parent_hash:
|
|
200
|
+
events.append(
|
|
201
|
+
SymbolHistoryEvent(
|
|
202
|
+
commit_sha=c.sha,
|
|
203
|
+
date=c.date,
|
|
204
|
+
author=c.author,
|
|
205
|
+
message=c.message,
|
|
206
|
+
event_type="MODIFIED",
|
|
207
|
+
path=current_sym_path,
|
|
208
|
+
confidence="FACT",
|
|
209
|
+
evidence=f"Symbol modified at line {curr_line} (body hash changed)",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
elif curr_hash is not None and parent_hash is None:
|
|
213
|
+
# Symbol appeared in this commit! Did it rename or move from somewhere else?
|
|
214
|
+
found_move = False
|
|
215
|
+
# Check git diff --name-status c.sha~1 c.sha for renames
|
|
216
|
+
raw_rename = _run_git(repository, "diff", "-M", "--name-status", f"{c.sha}~1", c.sha)
|
|
217
|
+
if raw_rename:
|
|
218
|
+
for line in raw_rename.splitlines():
|
|
219
|
+
if line.startswith("R") and current_sym_path in line:
|
|
220
|
+
parts = line.split("\t")
|
|
221
|
+
if len(parts) >= 3 and parts[2] == current_sym_path:
|
|
222
|
+
old_p = parts[1]
|
|
223
|
+
events.append(
|
|
224
|
+
SymbolHistoryEvent(
|
|
225
|
+
commit_sha=c.sha,
|
|
226
|
+
date=c.date,
|
|
227
|
+
author=c.author,
|
|
228
|
+
message=c.message,
|
|
229
|
+
event_type="MOVED",
|
|
230
|
+
path=current_sym_path,
|
|
231
|
+
old_path=old_p,
|
|
232
|
+
confidence="FACT",
|
|
233
|
+
evidence=f"File moved from {old_p} to {current_sym_path}",
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
current_sym_path = old_p
|
|
237
|
+
found_move = True
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
if not found_move:
|
|
241
|
+
# Check if introduced
|
|
242
|
+
events.append(
|
|
243
|
+
SymbolHistoryEvent(
|
|
244
|
+
commit_sha=c.sha,
|
|
245
|
+
date=c.date,
|
|
246
|
+
author=c.author,
|
|
247
|
+
message=c.message,
|
|
248
|
+
event_type="INTRODUCED",
|
|
249
|
+
path=current_sym_path,
|
|
250
|
+
confidence="FACT",
|
|
251
|
+
evidence=f"Symbol created at {current_sym_path}:{curr_line}",
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
break
|
|
255
|
+
elif curr_hash is None and parent_hash is not None:
|
|
256
|
+
# Symbol was deleted or renamed in curr_content
|
|
257
|
+
events.append(
|
|
258
|
+
SymbolHistoryEvent(
|
|
259
|
+
commit_sha=c.sha,
|
|
260
|
+
date=c.date,
|
|
261
|
+
author=c.author,
|
|
262
|
+
message=c.message,
|
|
263
|
+
event_type="DELETED",
|
|
264
|
+
path=current_sym_path,
|
|
265
|
+
confidence="FACT",
|
|
266
|
+
evidence=f"Symbol removed in commit {c.sha[:8]}",
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return SymbolHistoryReport(
|
|
271
|
+
symbol=symbol,
|
|
272
|
+
current_path=resolved_path,
|
|
273
|
+
events=events,
|
|
274
|
+
total_commits_evaluated=len(commits),
|
|
275
|
+
status="SUCCESS" if events else "NOT_FOUND",
|
|
276
|
+
detail=f"Traced {len(events)} history event(s) across {len(commits)} commits.",
|
|
277
|
+
)
|
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
|