drift-analyzer 0.5.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.
- drift/__init__.py +18 -0
- drift/__main__.py +6 -0
- drift/analyzer.py +370 -0
- drift/analyzers/typescript/alias_resolver.py +129 -0
- drift/analyzers/typescript/barrel_resolver.py +131 -0
- drift/analyzers/typescript/import_graph.py +172 -0
- drift/analyzers/typescript/workspace_boundaries.py +116 -0
- drift/cache.py +209 -0
- drift/cli.py +80 -0
- drift/commands/__init__.py +16 -0
- drift/commands/analyze.py +142 -0
- drift/commands/badge.py +75 -0
- drift/commands/check.py +100 -0
- drift/commands/patterns.py +70 -0
- drift/commands/self_analyze.py +61 -0
- drift/commands/timeline.py +42 -0
- drift/commands/trend.py +125 -0
- drift/config.py +127 -0
- drift/embeddings.py +294 -0
- drift/ingestion/__init__.py +12 -0
- drift/ingestion/ast_parser.py +509 -0
- drift/ingestion/file_discovery.py +156 -0
- drift/ingestion/git_history.py +281 -0
- drift/ingestion/ts_parser.py +452 -0
- drift/models.py +240 -0
- drift/output/__init__.py +18 -0
- drift/output/json_output.py +147 -0
- drift/output/rich_output.py +489 -0
- drift/py.typed +0 -0
- drift/recommendations.py +268 -0
- drift/rules/tsjs/cross_package_import_ban.py +93 -0
- drift/scoring/__init__.py +17 -0
- drift/scoring/engine.py +269 -0
- drift/signals/__init__.py +21 -0
- drift/signals/architecture_violation.py +454 -0
- drift/signals/base.py +108 -0
- drift/signals/doc_impl_drift.py +492 -0
- drift/signals/explainability_deficit.py +198 -0
- drift/signals/mutant_duplicates.py +484 -0
- drift/signals/pattern_fragmentation.py +175 -0
- drift/signals/system_misalignment.py +217 -0
- drift/signals/temporal_volatility.py +171 -0
- drift/suppression.py +93 -0
- drift/timeline.py +293 -0
- drift_analyzer-0.5.0.dist-info/METADATA +284 -0
- drift_analyzer-0.5.0.dist-info/RECORD +49 -0
- drift_analyzer-0.5.0.dist-info/WHEEL +4 -0
- drift_analyzer-0.5.0.dist-info/entry_points.txt +2 -0
- drift_analyzer-0.5.0.dist-info/licenses/LICENSE +21 -0
drift/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Drift — Detect architectural erosion from AI-generated code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _resolve_version() -> str:
|
|
9
|
+
"""Resolve installed package version for CLI/output metadata."""
|
|
10
|
+
for package_name in ("drift-analyzer", "drift"):
|
|
11
|
+
try:
|
|
12
|
+
return version(package_name)
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
continue
|
|
15
|
+
return "0.0.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__version__ = _resolve_version()
|
drift/__main__.py
ADDED
drift/analyzer.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Main analysis orchestrator — coordinates ingestion, signals, and scoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import importlib
|
|
7
|
+
import logging
|
|
8
|
+
import pkgutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import drift.signals
|
|
16
|
+
from drift.cache import ParseCache
|
|
17
|
+
from drift.config import DriftConfig
|
|
18
|
+
from drift.embeddings import get_embedding_service
|
|
19
|
+
from drift.ingestion.ast_parser import parse_file
|
|
20
|
+
from drift.ingestion.file_discovery import discover_files
|
|
21
|
+
from drift.ingestion.git_history import build_file_histories, parse_git_history
|
|
22
|
+
from drift.models import (
|
|
23
|
+
FileInfo,
|
|
24
|
+
Finding,
|
|
25
|
+
ParseResult,
|
|
26
|
+
PatternCategory,
|
|
27
|
+
PatternInstance,
|
|
28
|
+
RepoAnalysis,
|
|
29
|
+
)
|
|
30
|
+
from drift.scoring.engine import (
|
|
31
|
+
assign_impact_scores,
|
|
32
|
+
composite_score,
|
|
33
|
+
compute_module_scores,
|
|
34
|
+
compute_signal_scores,
|
|
35
|
+
)
|
|
36
|
+
from drift.signals.base import AnalysisContext, create_signals
|
|
37
|
+
from drift.suppression import filter_findings, scan_suppressions
|
|
38
|
+
|
|
39
|
+
# Auto-discover all signal modules so @register_signal decorators execute.
|
|
40
|
+
for _finder, _mod_name, _ispkg in pkgutil.iter_modules(drift.signals.__path__):
|
|
41
|
+
importlib.import_module(f"drift.signals.{_mod_name}")
|
|
42
|
+
|
|
43
|
+
# Progress callback: (phase_name, current, total)
|
|
44
|
+
ProgressCallback = Callable[[str, int, int], None]
|
|
45
|
+
|
|
46
|
+
# Default parallelism for file parsing — threads work well here because
|
|
47
|
+
# the bottleneck is disk I/O rather than pure CPU.
|
|
48
|
+
_DEFAULT_WORKERS = 8
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_git_repo(path: Path) -> bool:
|
|
52
|
+
"""Check whether *path* is inside a git working tree."""
|
|
53
|
+
try:
|
|
54
|
+
subprocess.run(
|
|
55
|
+
["git", "-C", str(path), "rev-parse", "--git-dir"],
|
|
56
|
+
capture_output=True,
|
|
57
|
+
check=True,
|
|
58
|
+
)
|
|
59
|
+
return True
|
|
60
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _fetch_git_history(
|
|
65
|
+
repo_path: Path, since_days: int, known_files: set[str],
|
|
66
|
+
ai_confidence_threshold: float = 0.50,
|
|
67
|
+
) -> tuple[list, dict]:
|
|
68
|
+
"""Run git history parsing (designed to run in a background thread)."""
|
|
69
|
+
commits = parse_git_history(
|
|
70
|
+
repo_path, since_days=since_days, file_filter=known_files,
|
|
71
|
+
ai_confidence_threshold=ai_confidence_threshold,
|
|
72
|
+
)
|
|
73
|
+
file_histories = build_file_histories(commits, known_files=known_files)
|
|
74
|
+
return commits, file_histories
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run_pipeline(
|
|
78
|
+
repo_path: Path,
|
|
79
|
+
files: list[FileInfo],
|
|
80
|
+
config: DriftConfig,
|
|
81
|
+
since_days: int = 90,
|
|
82
|
+
on_progress: ProgressCallback | None = None,
|
|
83
|
+
workers: int = _DEFAULT_WORKERS,
|
|
84
|
+
_start: float | None = None,
|
|
85
|
+
) -> RepoAnalysis:
|
|
86
|
+
"""Shared analysis pipeline: parse → git history → signals → score.
|
|
87
|
+
|
|
88
|
+
Both ``analyze_repo`` and ``analyze_diff`` delegate here after resolving
|
|
89
|
+
which files to analyse. Keeping the pipeline in one place eliminates
|
|
90
|
+
duplication and ensures every code-path benefits from caching, progress
|
|
91
|
+
reporting, and resilient signal execution.
|
|
92
|
+
"""
|
|
93
|
+
start = _start if _start is not None else time.monotonic()
|
|
94
|
+
|
|
95
|
+
def _progress(phase: str, current: int, total: int) -> None:
|
|
96
|
+
if on_progress:
|
|
97
|
+
on_progress(phase, current, total)
|
|
98
|
+
|
|
99
|
+
known_files = {f.path.as_posix() for f in files}
|
|
100
|
+
|
|
101
|
+
# --- 1. AST parsing (parallelized, cache-aware) ---
|
|
102
|
+
cache = ParseCache(repo_path / config.cache_dir)
|
|
103
|
+
|
|
104
|
+
cached_results: dict[int, ParseResult] = {}
|
|
105
|
+
# Keep the content hash for cache misses so we don't re-read each file
|
|
106
|
+
# after parsing just to compute the key again.
|
|
107
|
+
to_parse: list[tuple[int, FileInfo, str | None]] = []
|
|
108
|
+
for idx, finfo in enumerate(files):
|
|
109
|
+
full_path = repo_path / finfo.path
|
|
110
|
+
content_hash: str | None = None
|
|
111
|
+
try:
|
|
112
|
+
content_hash = ParseCache.file_hash(full_path)
|
|
113
|
+
hit = cache.get(content_hash)
|
|
114
|
+
if hit is not None:
|
|
115
|
+
cached_results[idx] = hit
|
|
116
|
+
continue
|
|
117
|
+
except OSError:
|
|
118
|
+
pass
|
|
119
|
+
to_parse.append((idx, finfo, content_hash))
|
|
120
|
+
|
|
121
|
+
_progress("Parsing files", len(cached_results), len(files))
|
|
122
|
+
|
|
123
|
+
has_git = _is_git_repo(repo_path)
|
|
124
|
+
|
|
125
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
126
|
+
# --- 2. Git history (concurrent with parsing) ---
|
|
127
|
+
git_future = (
|
|
128
|
+
executor.submit(
|
|
129
|
+
_fetch_git_history, repo_path, since_days, known_files,
|
|
130
|
+
config.thresholds.ai_confidence_threshold,
|
|
131
|
+
)
|
|
132
|
+
if has_git
|
|
133
|
+
else None
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
parse_results: list[ParseResult] = [None] * len(files) # type: ignore[list-item]
|
|
137
|
+
for idx, cached in cached_results.items():
|
|
138
|
+
parse_results[idx] = cached
|
|
139
|
+
|
|
140
|
+
if to_parse:
|
|
141
|
+
new_results: list[tuple[int, str, ParseResult]] = [None] * len(to_parse) # type: ignore[list-item]
|
|
142
|
+
futures = {
|
|
143
|
+
executor.submit(parse_file, finfo.path, repo_path, finfo.language): (
|
|
144
|
+
i,
|
|
145
|
+
idx,
|
|
146
|
+
content_hash,
|
|
147
|
+
)
|
|
148
|
+
for i, (idx, finfo, content_hash) in enumerate(to_parse)
|
|
149
|
+
}
|
|
150
|
+
for future in as_completed(futures):
|
|
151
|
+
i, idx, content_hash = futures[future]
|
|
152
|
+
result = future.result()
|
|
153
|
+
parse_results[idx] = result
|
|
154
|
+
if content_hash is not None:
|
|
155
|
+
new_results[i] = (idx, content_hash, result)
|
|
156
|
+
|
|
157
|
+
for entry in new_results:
|
|
158
|
+
if entry is not None:
|
|
159
|
+
_idx, h, r = entry
|
|
160
|
+
cache.put(h, r)
|
|
161
|
+
|
|
162
|
+
_progress("Parsing files", len(files), len(files))
|
|
163
|
+
|
|
164
|
+
if git_future is not None:
|
|
165
|
+
commits, file_histories = git_future.result()
|
|
166
|
+
else:
|
|
167
|
+
logging.getLogger("drift").info("Not a git repository — skipping git history analysis.")
|
|
168
|
+
commits, file_histories = [], {}
|
|
169
|
+
|
|
170
|
+
_progress("Analyzing git history", 0, 0)
|
|
171
|
+
|
|
172
|
+
# --- 3. Embedding service ---
|
|
173
|
+
emb_svc = None
|
|
174
|
+
if config.embeddings_enabled:
|
|
175
|
+
emb_svc = get_embedding_service(
|
|
176
|
+
cache_dir=repo_path / config.cache_dir,
|
|
177
|
+
model_name=config.embedding_model,
|
|
178
|
+
batch_size=config.embedding_batch_size,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# --- 4. Signals ---
|
|
182
|
+
ctx = AnalysisContext(
|
|
183
|
+
repo_path=repo_path,
|
|
184
|
+
config=config,
|
|
185
|
+
parse_results=parse_results,
|
|
186
|
+
file_histories=file_histories,
|
|
187
|
+
embedding_service=emb_svc,
|
|
188
|
+
)
|
|
189
|
+
signals = create_signals(ctx)
|
|
190
|
+
|
|
191
|
+
all_findings: list[Finding] = []
|
|
192
|
+
total_signals = len(signals)
|
|
193
|
+
for i, signal in enumerate(signals):
|
|
194
|
+
_progress(f"Signal: {signal.name}", i + 1, total_signals)
|
|
195
|
+
try:
|
|
196
|
+
findings = signal.analyze(parse_results, file_histories, config)
|
|
197
|
+
all_findings.extend(findings)
|
|
198
|
+
except Exception:
|
|
199
|
+
logging.getLogger("drift").warning(
|
|
200
|
+
"Signal '%s' failed; skipping.",
|
|
201
|
+
signal.name,
|
|
202
|
+
exc_info=True,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# --- 5. Scoring ---
|
|
206
|
+
assign_impact_scores(all_findings, config.weights)
|
|
207
|
+
|
|
208
|
+
# --- 5b. Inline suppression ---
|
|
209
|
+
suppressions = scan_suppressions(files, repo_path)
|
|
210
|
+
all_findings, suppressed_findings = filter_findings(all_findings, suppressions)
|
|
211
|
+
suppressed_count = len(suppressed_findings)
|
|
212
|
+
|
|
213
|
+
signal_scores = compute_signal_scores(all_findings)
|
|
214
|
+
repo_score = composite_score(signal_scores, config.weights)
|
|
215
|
+
module_scores = compute_module_scores(all_findings, config.weights)
|
|
216
|
+
|
|
217
|
+
# --- 6. Pattern catalog ---
|
|
218
|
+
pattern_catalog: dict[PatternCategory, list[PatternInstance]] = {}
|
|
219
|
+
for pr in parse_results:
|
|
220
|
+
for pattern in pr.patterns:
|
|
221
|
+
pattern_catalog.setdefault(pattern.category, []).append(pattern)
|
|
222
|
+
|
|
223
|
+
# --- 7. Assemble result ---
|
|
224
|
+
total_funcs = sum(len(pr.functions) for pr in parse_results)
|
|
225
|
+
ai_commits = sum(1 for c in commits if c.is_ai_attributed)
|
|
226
|
+
ai_ratio = ai_commits / max(1, len(commits))
|
|
227
|
+
|
|
228
|
+
duration = time.monotonic() - start
|
|
229
|
+
|
|
230
|
+
return RepoAnalysis(
|
|
231
|
+
repo_path=repo_path,
|
|
232
|
+
analyzed_at=datetime.datetime.now(tz=datetime.UTC),
|
|
233
|
+
drift_score=repo_score,
|
|
234
|
+
module_scores=module_scores,
|
|
235
|
+
findings=all_findings,
|
|
236
|
+
pattern_catalog=pattern_catalog,
|
|
237
|
+
total_files=len(files),
|
|
238
|
+
total_functions=total_funcs,
|
|
239
|
+
ai_attributed_ratio=round(ai_ratio, 3),
|
|
240
|
+
analysis_duration_seconds=round(duration, 2),
|
|
241
|
+
commits=commits,
|
|
242
|
+
file_histories=file_histories,
|
|
243
|
+
suppressed_count=suppressed_count,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
# Public entry points
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def analyze_repo(
|
|
253
|
+
repo_path: Path,
|
|
254
|
+
config: DriftConfig | None = None,
|
|
255
|
+
since_days: int = 90,
|
|
256
|
+
target_path: str | None = None,
|
|
257
|
+
on_progress: ProgressCallback | None = None,
|
|
258
|
+
workers: int = _DEFAULT_WORKERS,
|
|
259
|
+
) -> RepoAnalysis:
|
|
260
|
+
"""Run full drift analysis on a repository.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
repo_path: Absolute path to the repository root.
|
|
264
|
+
config: Drift configuration. Loaded from drift.yaml if None.
|
|
265
|
+
since_days: How many days of git history to analyze.
|
|
266
|
+
target_path: Optional subdirectory to restrict analysis to.
|
|
267
|
+
on_progress: Optional callback (phase, current, total) for progress display.
|
|
268
|
+
workers: Number of parallel parsing threads.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Complete RepoAnalysis with scores, findings, and module breakdowns.
|
|
272
|
+
"""
|
|
273
|
+
repo_path = repo_path.resolve()
|
|
274
|
+
start = time.monotonic()
|
|
275
|
+
|
|
276
|
+
if config is None:
|
|
277
|
+
config = DriftConfig.load(repo_path)
|
|
278
|
+
|
|
279
|
+
if on_progress:
|
|
280
|
+
on_progress("Discovering files", 0, 0)
|
|
281
|
+
|
|
282
|
+
files = discover_files(
|
|
283
|
+
repo_path,
|
|
284
|
+
include=config.include,
|
|
285
|
+
exclude=config.exclude,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
if target_path:
|
|
289
|
+
target = Path(target_path)
|
|
290
|
+
files = [f for f in files if str(f.path).startswith(str(target))]
|
|
291
|
+
|
|
292
|
+
return _run_pipeline(
|
|
293
|
+
repo_path, files, config,
|
|
294
|
+
since_days=since_days,
|
|
295
|
+
on_progress=on_progress,
|
|
296
|
+
workers=workers,
|
|
297
|
+
_start=start,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def analyze_diff(
|
|
302
|
+
repo_path: Path,
|
|
303
|
+
config: DriftConfig | None = None,
|
|
304
|
+
diff_ref: str = "HEAD~1",
|
|
305
|
+
workers: int = _DEFAULT_WORKERS,
|
|
306
|
+
on_progress: ProgressCallback | None = None,
|
|
307
|
+
since_days: int = 90,
|
|
308
|
+
) -> RepoAnalysis:
|
|
309
|
+
"""Analyze only files changed since a given git ref.
|
|
310
|
+
|
|
311
|
+
Useful for CI — only checks files in the current diff.
|
|
312
|
+
Runs signals only on changed files rather than the entire repo.
|
|
313
|
+
"""
|
|
314
|
+
logger = logging.getLogger("drift")
|
|
315
|
+
repo_path = repo_path.resolve()
|
|
316
|
+
start = time.monotonic()
|
|
317
|
+
|
|
318
|
+
if config is None:
|
|
319
|
+
config = DriftConfig.load(repo_path)
|
|
320
|
+
|
|
321
|
+
# Get changed files from git (subprocess per ADR-004)
|
|
322
|
+
changed_files: list[str] = []
|
|
323
|
+
try:
|
|
324
|
+
result = subprocess.run(
|
|
325
|
+
["git", "diff", "--name-only", diff_ref],
|
|
326
|
+
capture_output=True,
|
|
327
|
+
text=True,
|
|
328
|
+
encoding="utf-8",
|
|
329
|
+
errors="replace",
|
|
330
|
+
cwd=repo_path,
|
|
331
|
+
check=True,
|
|
332
|
+
)
|
|
333
|
+
changed_files = [line for line in result.stdout.strip().splitlines() if line]
|
|
334
|
+
except Exception as exc:
|
|
335
|
+
logger.warning(
|
|
336
|
+
"Could not resolve diff ref '%s': %s. Falling back to full analysis.",
|
|
337
|
+
diff_ref,
|
|
338
|
+
exc,
|
|
339
|
+
)
|
|
340
|
+
return analyze_repo(repo_path, config, workers=workers)
|
|
341
|
+
|
|
342
|
+
if not changed_files:
|
|
343
|
+
return RepoAnalysis(
|
|
344
|
+
repo_path=repo_path,
|
|
345
|
+
analyzed_at=datetime.datetime.now(tz=datetime.UTC),
|
|
346
|
+
drift_score=0.0,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
all_files = discover_files(
|
|
350
|
+
repo_path,
|
|
351
|
+
include=config.include,
|
|
352
|
+
exclude=config.exclude,
|
|
353
|
+
)
|
|
354
|
+
changed_set = set(changed_files)
|
|
355
|
+
files = [f for f in all_files if f.path.as_posix() in changed_set]
|
|
356
|
+
|
|
357
|
+
if not files:
|
|
358
|
+
return RepoAnalysis(
|
|
359
|
+
repo_path=repo_path,
|
|
360
|
+
analyzed_at=datetime.datetime.now(tz=datetime.UTC),
|
|
361
|
+
drift_score=0.0,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return _run_pipeline(
|
|
365
|
+
repo_path, files, config,
|
|
366
|
+
since_days=since_days,
|
|
367
|
+
on_progress=on_progress,
|
|
368
|
+
workers=workers,
|
|
369
|
+
_start=start,
|
|
370
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Resolve TypeScript path aliases defined in tsconfig.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_ALLOWED_EXTENSIONS = {".ts", ".tsx"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_compiler_options(tsconfig_path: Path) -> dict[str, object]:
|
|
12
|
+
"""Load compilerOptions from tsconfig.json, returning an empty mapping on errors."""
|
|
13
|
+
try:
|
|
14
|
+
data = json.loads(tsconfig_path.read_text(encoding="utf-8"))
|
|
15
|
+
except (OSError, json.JSONDecodeError):
|
|
16
|
+
return {}
|
|
17
|
+
|
|
18
|
+
compiler_options = data.get("compilerOptions", {})
|
|
19
|
+
if not isinstance(compiler_options, dict):
|
|
20
|
+
return {}
|
|
21
|
+
return compiler_options
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _match_alias_pattern(alias_pattern: str, module_spec: str) -> str | None:
|
|
25
|
+
"""Return wildcard capture for matching alias pattern or None if no match."""
|
|
26
|
+
if "*" not in alias_pattern:
|
|
27
|
+
return "" if alias_pattern == module_spec else None
|
|
28
|
+
|
|
29
|
+
if alias_pattern.count("*") != 1:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
prefix, suffix = alias_pattern.split("*")
|
|
33
|
+
if not module_spec.startswith(prefix):
|
|
34
|
+
return None
|
|
35
|
+
if suffix and not module_spec.endswith(suffix):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
captured = module_spec[len(prefix) : len(module_spec) - len(suffix) if suffix else None]
|
|
39
|
+
return captured
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _expand_target_pattern(target_pattern: str, wildcard_capture: str) -> str | None:
|
|
43
|
+
"""Expand target path pattern with wildcard capture."""
|
|
44
|
+
if "*" not in target_pattern:
|
|
45
|
+
return target_pattern if wildcard_capture == "" else None
|
|
46
|
+
|
|
47
|
+
if target_pattern.count("*") != 1:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
return target_pattern.replace("*", wildcard_capture)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_candidate_file(base_candidate: Path) -> Path | None:
|
|
54
|
+
"""Resolve a candidate path to an existing .ts/.tsx file."""
|
|
55
|
+
if base_candidate.suffix in _ALLOWED_EXTENSIONS:
|
|
56
|
+
return base_candidate if base_candidate.is_file() else None
|
|
57
|
+
|
|
58
|
+
for suffix in (".ts", ".tsx"):
|
|
59
|
+
with_suffix = Path(f"{base_candidate.as_posix()}{suffix}")
|
|
60
|
+
if with_suffix.is_file():
|
|
61
|
+
return with_suffix
|
|
62
|
+
|
|
63
|
+
for index_name in ("index.ts", "index.tsx"):
|
|
64
|
+
index_file = base_candidate / index_name
|
|
65
|
+
if index_file.is_file():
|
|
66
|
+
return index_file
|
|
67
|
+
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_tsconfig_alias_import(
|
|
72
|
+
repo_path: Path,
|
|
73
|
+
source_path: Path,
|
|
74
|
+
module_spec: str,
|
|
75
|
+
) -> Path | None:
|
|
76
|
+
"""Resolve a TS alias import to a repository-relative .ts/.tsx path.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
repo_path: Repository root.
|
|
80
|
+
source_path: Repository-relative source file path (reserved for API parity).
|
|
81
|
+
module_spec: Import module specifier.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Repository-relative target file path if resolved, otherwise None.
|
|
85
|
+
"""
|
|
86
|
+
_ = source_path
|
|
87
|
+
|
|
88
|
+
if module_spec.startswith("./") or module_spec.startswith("../"):
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
tsconfig_path = repo_path / "tsconfig.json"
|
|
92
|
+
if not tsconfig_path.is_file():
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
compiler_options = _load_compiler_options(tsconfig_path)
|
|
96
|
+
base_url = compiler_options.get("baseUrl", ".")
|
|
97
|
+
paths = compiler_options.get("paths", {})
|
|
98
|
+
|
|
99
|
+
if not isinstance(base_url, str) or not isinstance(paths, dict):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
base_dir = tsconfig_path.parent / Path(base_url)
|
|
103
|
+
|
|
104
|
+
for alias_pattern, target_patterns in paths.items():
|
|
105
|
+
if not isinstance(alias_pattern, str) or not isinstance(target_patterns, list):
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
wildcard_capture = _match_alias_pattern(alias_pattern, module_spec)
|
|
109
|
+
if wildcard_capture is None:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
for target_pattern in target_patterns:
|
|
113
|
+
if not isinstance(target_pattern, str):
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
expanded = _expand_target_pattern(target_pattern, wildcard_capture)
|
|
117
|
+
if expanded is None:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
resolved = _resolve_candidate_file(base_dir / Path(expanded))
|
|
121
|
+
if resolved is None:
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
return resolved.relative_to(repo_path)
|
|
126
|
+
except ValueError:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
return None
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Resolve one-hop TypeScript barrel re-exports from index.ts files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import posixpath
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_EXPORT_STAR_RE = re.compile(
|
|
11
|
+
r"^\s*export\s+\*\s+from\s+[\"']([^\"']+)[\"']\s*;?\s*$"
|
|
12
|
+
)
|
|
13
|
+
_EXPORT_NAMED_RE = re.compile(
|
|
14
|
+
r"^\s*export\s*\{([^}]*)\}\s*from\s+[\"']([^\"']+)[\"']\s*;?\s*$"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class BarrelExport:
|
|
20
|
+
"""Represents a single barrel re-export statement."""
|
|
21
|
+
|
|
22
|
+
module_spec: str
|
|
23
|
+
exported_names: set[str] | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_rel_path(path: Path) -> Path:
|
|
27
|
+
"""Normalize a repository-relative path using POSIX semantics."""
|
|
28
|
+
return Path(posixpath.normpath(path.as_posix()))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_relative_target(repo_path: Path, source_path: Path, module_spec: str) -> Path | None:
|
|
32
|
+
"""Resolve relative TS module specifier to repository-relative target file."""
|
|
33
|
+
if not (module_spec.startswith("./") or module_spec.startswith("../")):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
base_candidate = _normalize_rel_path(source_path.parent / module_spec)
|
|
37
|
+
|
|
38
|
+
if base_candidate.suffix in {".ts", ".tsx"}:
|
|
39
|
+
explicit = repo_path / base_candidate
|
|
40
|
+
return base_candidate if explicit.is_file() else None
|
|
41
|
+
|
|
42
|
+
for suffix in (".ts", ".tsx"):
|
|
43
|
+
candidate = _normalize_rel_path(Path(f"{base_candidate.as_posix()}{suffix}"))
|
|
44
|
+
if (repo_path / candidate).is_file():
|
|
45
|
+
return candidate
|
|
46
|
+
|
|
47
|
+
for index_name in ("index.ts", "index.tsx"):
|
|
48
|
+
index_candidate = _normalize_rel_path(base_candidate / index_name)
|
|
49
|
+
if (repo_path / index_candidate).is_file():
|
|
50
|
+
return index_candidate
|
|
51
|
+
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_named_export_names(export_clause: str) -> set[str]:
|
|
56
|
+
"""Parse exported names from ``export { ... } from`` clause."""
|
|
57
|
+
names: set[str] = set()
|
|
58
|
+
for part in export_clause.split(","):
|
|
59
|
+
token = part.strip()
|
|
60
|
+
if not token:
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
if token.startswith("type "):
|
|
64
|
+
token = token[len("type ") :].strip()
|
|
65
|
+
|
|
66
|
+
if " as " in token:
|
|
67
|
+
_, exported_name = token.split(" as ", 1)
|
|
68
|
+
exported_name = exported_name.strip()
|
|
69
|
+
if exported_name:
|
|
70
|
+
names.add(exported_name)
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
names.add(token)
|
|
74
|
+
|
|
75
|
+
return names
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _extract_barrel_exports(index_text: str) -> list[BarrelExport]:
|
|
79
|
+
"""Extract one-hop re-exports from an index.ts source file."""
|
|
80
|
+
exports: list[BarrelExport] = []
|
|
81
|
+
|
|
82
|
+
for line in index_text.splitlines():
|
|
83
|
+
star_match = _EXPORT_STAR_RE.match(line)
|
|
84
|
+
if star_match:
|
|
85
|
+
exports.append(BarrelExport(module_spec=star_match.group(1), exported_names=None))
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
named_match = _EXPORT_NAMED_RE.match(line)
|
|
89
|
+
if named_match:
|
|
90
|
+
exports.append(
|
|
91
|
+
BarrelExport(
|
|
92
|
+
module_spec=named_match.group(2),
|
|
93
|
+
exported_names=_parse_named_export_names(named_match.group(1)),
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return exports
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def resolve_index_barrel_target(
|
|
101
|
+
repo_path: Path,
|
|
102
|
+
index_path: Path,
|
|
103
|
+
imported_symbols: set[str] | None,
|
|
104
|
+
) -> Path | None:
|
|
105
|
+
"""Resolve imports targeting index.ts to a one-hop re-export source file.
|
|
106
|
+
|
|
107
|
+
Returns a repository-relative target file only when a single unambiguous
|
|
108
|
+
re-export target can be selected.
|
|
109
|
+
"""
|
|
110
|
+
if index_path.name != "index.ts":
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
index_text = (repo_path / index_path).read_text(encoding="utf-8", errors="replace")
|
|
114
|
+
candidates: set[Path] = set()
|
|
115
|
+
|
|
116
|
+
for barrel_export in _extract_barrel_exports(index_text):
|
|
117
|
+
if barrel_export.exported_names is not None:
|
|
118
|
+
if imported_symbols is None:
|
|
119
|
+
continue
|
|
120
|
+
if not (barrel_export.exported_names & imported_symbols):
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
resolved = _resolve_relative_target(repo_path, index_path, barrel_export.module_spec)
|
|
124
|
+
if resolved is None:
|
|
125
|
+
continue
|
|
126
|
+
candidates.add(resolved)
|
|
127
|
+
|
|
128
|
+
if len(candidates) != 1:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
return next(iter(candidates))
|