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.
Files changed (49) hide show
  1. drift/__init__.py +18 -0
  2. drift/__main__.py +6 -0
  3. drift/analyzer.py +370 -0
  4. drift/analyzers/typescript/alias_resolver.py +129 -0
  5. drift/analyzers/typescript/barrel_resolver.py +131 -0
  6. drift/analyzers/typescript/import_graph.py +172 -0
  7. drift/analyzers/typescript/workspace_boundaries.py +116 -0
  8. drift/cache.py +209 -0
  9. drift/cli.py +80 -0
  10. drift/commands/__init__.py +16 -0
  11. drift/commands/analyze.py +142 -0
  12. drift/commands/badge.py +75 -0
  13. drift/commands/check.py +100 -0
  14. drift/commands/patterns.py +70 -0
  15. drift/commands/self_analyze.py +61 -0
  16. drift/commands/timeline.py +42 -0
  17. drift/commands/trend.py +125 -0
  18. drift/config.py +127 -0
  19. drift/embeddings.py +294 -0
  20. drift/ingestion/__init__.py +12 -0
  21. drift/ingestion/ast_parser.py +509 -0
  22. drift/ingestion/file_discovery.py +156 -0
  23. drift/ingestion/git_history.py +281 -0
  24. drift/ingestion/ts_parser.py +452 -0
  25. drift/models.py +240 -0
  26. drift/output/__init__.py +18 -0
  27. drift/output/json_output.py +147 -0
  28. drift/output/rich_output.py +489 -0
  29. drift/py.typed +0 -0
  30. drift/recommendations.py +268 -0
  31. drift/rules/tsjs/cross_package_import_ban.py +93 -0
  32. drift/scoring/__init__.py +17 -0
  33. drift/scoring/engine.py +269 -0
  34. drift/signals/__init__.py +21 -0
  35. drift/signals/architecture_violation.py +454 -0
  36. drift/signals/base.py +108 -0
  37. drift/signals/doc_impl_drift.py +492 -0
  38. drift/signals/explainability_deficit.py +198 -0
  39. drift/signals/mutant_duplicates.py +484 -0
  40. drift/signals/pattern_fragmentation.py +175 -0
  41. drift/signals/system_misalignment.py +217 -0
  42. drift/signals/temporal_volatility.py +171 -0
  43. drift/suppression.py +93 -0
  44. drift/timeline.py +293 -0
  45. drift_analyzer-0.5.0.dist-info/METADATA +284 -0
  46. drift_analyzer-0.5.0.dist-info/RECORD +49 -0
  47. drift_analyzer-0.5.0.dist-info/WHEEL +4 -0
  48. drift_analyzer-0.5.0.dist-info/entry_points.txt +2 -0
  49. 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
@@ -0,0 +1,6 @@
1
+ """Allow running drift as a module: python -m drift."""
2
+
3
+ from drift.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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))