dotscope 0.1.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 (114) hide show
  1. dotscope/.scope +63 -0
  2. dotscope/__init__.py +3 -0
  3. dotscope/absorber.py +390 -0
  4. dotscope/assertions.py +128 -0
  5. dotscope/ast_analyzer.py +2 -0
  6. dotscope/backtest.py +2 -0
  7. dotscope/bench.py +141 -0
  8. dotscope/budget.py +3 -0
  9. dotscope/cache.py +2 -0
  10. dotscope/check/__init__.py +1 -0
  11. dotscope/check/acknowledge.py +2 -0
  12. dotscope/check/checker.py +3 -0
  13. dotscope/check/checks/__init__.py +1 -0
  14. dotscope/check/checks/antipattern.py +2 -0
  15. dotscope/check/checks/boundary.py +2 -0
  16. dotscope/check/checks/contracts.py +3 -0
  17. dotscope/check/checks/direction.py +2 -0
  18. dotscope/check/checks/intent.py +2 -0
  19. dotscope/check/checks/stability.py +2 -0
  20. dotscope/check/constraints.py +2 -0
  21. dotscope/check/models.py +15 -0
  22. dotscope/cli.py +1447 -0
  23. dotscope/composer.py +147 -0
  24. dotscope/constants.py +45 -0
  25. dotscope/context.py +60 -0
  26. dotscope/counterfactual.py +180 -0
  27. dotscope/debug.py +220 -0
  28. dotscope/discovery.py +104 -0
  29. dotscope/formatter.py +157 -0
  30. dotscope/graph.py +3 -0
  31. dotscope/health.py +212 -0
  32. dotscope/help.py +204 -0
  33. dotscope/history.py +6 -0
  34. dotscope/hooks.py +2 -0
  35. dotscope/ingest.py +858 -0
  36. dotscope/intent.py +618 -0
  37. dotscope/lessons.py +223 -0
  38. dotscope/matcher.py +104 -0
  39. dotscope/mcp_server.py +1081 -0
  40. dotscope/models/.scope +45 -0
  41. dotscope/models/__init__.py +7 -0
  42. dotscope/models/core.py +288 -0
  43. dotscope/models/history.py +73 -0
  44. dotscope/models/intent.py +213 -0
  45. dotscope/models/passes.py +58 -0
  46. dotscope/models/state.py +250 -0
  47. dotscope/models.py +9 -0
  48. dotscope/near_miss.py +3 -0
  49. dotscope/onboarding.py +2 -0
  50. dotscope/parser.py +387 -0
  51. dotscope/passes/.scope +105 -0
  52. dotscope/passes/__init__.py +1 -0
  53. dotscope/passes/ast_analyzer.py +508 -0
  54. dotscope/passes/backtest.py +198 -0
  55. dotscope/passes/budget_allocator.py +164 -0
  56. dotscope/passes/convention_compliance.py +40 -0
  57. dotscope/passes/convention_discovery.py +247 -0
  58. dotscope/passes/convention_parser.py +223 -0
  59. dotscope/passes/graph_builder.py +299 -0
  60. dotscope/passes/history_miner.py +336 -0
  61. dotscope/passes/incremental.py +149 -0
  62. dotscope/passes/lang/__init__.py +38 -0
  63. dotscope/passes/lang/_base.py +20 -0
  64. dotscope/passes/lang/_treesitter.py +93 -0
  65. dotscope/passes/lang/go.py +333 -0
  66. dotscope/passes/lang/javascript.py +348 -0
  67. dotscope/passes/lazy.py +152 -0
  68. dotscope/passes/semantic_diff.py +160 -0
  69. dotscope/passes/sentinel/__init__.py +1 -0
  70. dotscope/passes/sentinel/acknowledge.py +222 -0
  71. dotscope/passes/sentinel/checker.py +383 -0
  72. dotscope/passes/sentinel/checks/__init__.py +1 -0
  73. dotscope/passes/sentinel/checks/antipattern.py +84 -0
  74. dotscope/passes/sentinel/checks/boundary.py +46 -0
  75. dotscope/passes/sentinel/checks/contracts.py +148 -0
  76. dotscope/passes/sentinel/checks/convention.py +54 -0
  77. dotscope/passes/sentinel/checks/direction.py +71 -0
  78. dotscope/passes/sentinel/checks/intent.py +207 -0
  79. dotscope/passes/sentinel/checks/stability.py +66 -0
  80. dotscope/passes/sentinel/checks/voice.py +108 -0
  81. dotscope/passes/sentinel/constraints.py +472 -0
  82. dotscope/passes/sentinel/line_filter.py +88 -0
  83. dotscope/passes/sentinel/models.py +15 -0
  84. dotscope/passes/virtual.py +239 -0
  85. dotscope/passes/voice.py +162 -0
  86. dotscope/passes/voice_defaults.py +28 -0
  87. dotscope/passes/voice_discovery.py +245 -0
  88. dotscope/paths.py +32 -0
  89. dotscope/progress.py +44 -0
  90. dotscope/regression.py +147 -0
  91. dotscope/resolver.py +203 -0
  92. dotscope/scanner.py +246 -0
  93. dotscope/sessions.py +2 -0
  94. dotscope/storage/.scope +64 -0
  95. dotscope/storage/__init__.py +1 -0
  96. dotscope/storage/cache.py +114 -0
  97. dotscope/storage/claude_hooks.py +119 -0
  98. dotscope/storage/git_hooks.py +277 -0
  99. dotscope/storage/incremental_state.py +61 -0
  100. dotscope/storage/mcp_config.py +98 -0
  101. dotscope/storage/near_miss.py +183 -0
  102. dotscope/storage/onboarding.py +150 -0
  103. dotscope/storage/session_manager.py +195 -0
  104. dotscope/storage/timing.py +84 -0
  105. dotscope/timing.py +2 -0
  106. dotscope/tokens.py +53 -0
  107. dotscope/utility.py +123 -0
  108. dotscope/virtual.py +3 -0
  109. dotscope/visibility.py +664 -0
  110. dotscope-0.1.0.dist-info/METADATA +50 -0
  111. dotscope-0.1.0.dist-info/RECORD +114 -0
  112. dotscope-0.1.0.dist-info/WHEEL +4 -0
  113. dotscope-0.1.0.dist-info/entry_points.txt +3 -0
  114. dotscope-0.1.0.dist-info/licenses/LICENSE +21 -0
dotscope/passes/.scope ADDED
@@ -0,0 +1,105 @@
1
+ description: Analysis engine — the active verbs of the compiler
2
+ includes:
3
+ - ast_analyzer.py
4
+ - graph_builder.py
5
+ - history_miner.py
6
+ - budget_allocator.py
7
+ - backtest.py
8
+ - virtual.py
9
+ - convention_discovery.py
10
+ - convention_parser.py
11
+ - convention_compliance.py
12
+ - semantic_diff.py
13
+ - voice_discovery.py
14
+ - voice_defaults.py
15
+ - voice.py
16
+ - lang/
17
+ - lazy.py
18
+ - incremental.py
19
+ - sentinel/
20
+ excludes:
21
+ - __pycache__/
22
+ context: |
23
+ Passes produce or consume models. They are the operations of the compiler.
24
+
25
+ ## ast_analyzer.py
26
+ Python AST analysis: imports (relative, star, conditional, TYPE_CHECKING),
27
+ function signatures, class hierarchies, decorators, public/private detection.
28
+ Populates models.core.FileAnalysis.
29
+
30
+ ## graph_builder.py
31
+ Builds DependencyGraph from import analysis. Module boundary detection
32
+ by directory cohesion. Transitive dependents computation. Cross-cutting
33
+ hub identification.
34
+
35
+ ## history_miner.py
36
+ Mines git log with --numstat for line-change data. Computes change
37
+ coupling, implicit contracts (P(B|A) >= 0.7), file stability
38
+ (stable/volatile/tweaked), hotspots, and recent summaries per module.
39
+
40
+ ## budget_allocator.py
41
+ Token budgeting. Context loads first. Files ranked by utility score
42
+ (from observations) × relevance × size. Asserted files get infinite
43
+ utility — ContextExhaustionError if budget can't fit them.
44
+
45
+ ## convention_discovery.py
46
+ Multi-pass clustering: shared decorators, base classes, naming suffixes.
47
+ Groups of 3+ files produce ConventionRule with match criteria and rules.
48
+ Runs during ingest after graph build.
49
+
50
+ ## convention_parser.py
51
+ Matches files to conventions via any_of/all_of criteria.
52
+ Checks rules: prohibited_imports, required_methods, must_have_matching.
53
+ Produces ConventionNode per file-convention match.
54
+
55
+ ## convention_compliance.py
56
+ Computes compliance ratio per convention. Severity thresholds:
57
+ >=80% hold, 50-79% note, <50% retired.
58
+
59
+ ## semantic_diff.py
60
+ Translates git diff into convention-level structural changes.
61
+ Parses AST at HEAD and working directory, compares ConventionNode graphs.
62
+
63
+ ## voice_discovery.py
64
+ Scans every function, docstring, exception handler in the codebase.
65
+ Produces VoiceStats: type hint rate, docstring style, bare except rate,
66
+ early return rate, comprehension density. Synthesizes DiscoveredVoice.
67
+
68
+ ## voice_defaults.py
69
+ Prescriptive voice config for new codebases (<10 files or <20 commits).
70
+ Strict type hints, Google docstrings, no bare excepts, early returns.
71
+
72
+ ## voice.py
73
+ Injection logic. Attaches global voice and convention-specific voice
74
+ (with canonical snippet) to resolve responses. AST-based canonical
75
+ snippet extraction.
76
+
77
+ ## lazy.py
78
+ On-demand single-module ingest. Builds partial graph (one level of
79
+ imports), mines filtered history (50 commits), synthesizes one scope.
80
+ Called by composer.py when find_scope() returns None.
81
+
82
+ ## incremental.py
83
+ Post-commit scope evolution. Adds new files to scope includes,
84
+ removes deleted files, updates stabilities in invariants.json.
85
+ Called by CLI incremental subcommand from the post-commit hook.
86
+
87
+ ## sentinel/ — Enforcement Engine
88
+ 8 checks: boundary, contracts, antipattern, convention, voice, direction, stability, intent.
89
+ constraints.py: prophylactic injection into resolve responses.
90
+ acknowledge.py: confidence decay (min floor 0.3).
91
+ Three modes: prophylactic (at resolve), diagnostic (dotscope_check),
92
+ gate (pre-commit hook).
93
+
94
+ ## Gotchas
95
+ Sentinel checks import from models.intent, never from each other.
96
+ Budget allocator raises ContextExhaustionError (not returns error).
97
+ Python uses stdlib ast; JS/TS/Go use tree-sitter via lang/ package.
98
+ related:
99
+ - dotscope/models/.scope
100
+ tags:
101
+ - analysis
102
+ - enforcement
103
+ - ast
104
+ - graph
105
+ tokens_estimate: 8200
@@ -0,0 +1 @@
1
+ """Analysis passes: the verbs of dotscope."""
@@ -0,0 +1,508 @@
1
+ """AST-powered code analysis. Structural understanding of source files.
2
+
3
+ Python: uses stdlib `ast` module — zero dependencies.
4
+ JS/TS: enhanced regex (no heavy AST dep for v1).
5
+ Go: enhanced regex.
6
+ """
7
+
8
+ import ast
9
+ import os
10
+ import re
11
+ from typing import Optional
12
+
13
+ from ..models import (
14
+ ClassInfo,
15
+ ExportedSymbol,
16
+ FileAnalysis,
17
+ FunctionInfo,
18
+ ResolvedImport,
19
+ )
20
+
21
+ # Cache: (path, mtime) → FileAnalysis
22
+ _analysis_cache: dict[tuple[str, float], FileAnalysis] = {}
23
+
24
+
25
+ def analyze_file(filepath: str, language: str) -> Optional[FileAnalysis]:
26
+ """Analyze a source file and extract its full structural API.
27
+
28
+ Results are cached by (path, mtime) to avoid re-parsing unchanged files.
29
+ """
30
+ try:
31
+ mtime = os.path.getmtime(filepath)
32
+ cache_key = (filepath, mtime)
33
+ if cache_key in _analysis_cache:
34
+ return _analysis_cache[cache_key]
35
+ except OSError:
36
+ pass
37
+
38
+ try:
39
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
40
+ source = f.read()
41
+ except (IOError, OSError):
42
+ return None
43
+
44
+ result = None
45
+ if language == "python":
46
+ result = _analyze_python(filepath, source)
47
+ else:
48
+ # tree-sitter for JS/TS/Go, regex fallback
49
+ from .lang import get_analyzer
50
+ ts_analyzer = get_analyzer(language)
51
+ if ts_analyzer:
52
+ result = ts_analyzer(filepath, source)
53
+ if result is None:
54
+ if language in ("javascript", "typescript"):
55
+ result = _analyze_js(filepath, source)
56
+ elif language == "go":
57
+ result = _analyze_go(filepath, source)
58
+
59
+ if result:
60
+ try:
61
+ _analysis_cache[(filepath, os.path.getmtime(filepath))] = result
62
+ except OSError:
63
+ pass
64
+
65
+ return result
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Python AST analysis
70
+ # ---------------------------------------------------------------------------
71
+
72
+ def _analyze_python(filepath: str, source: str) -> Optional[FileAnalysis]:
73
+ """Full AST walk of a Python file."""
74
+ try:
75
+ tree = ast.parse(source, filename=filepath)
76
+ except SyntaxError:
77
+ return None
78
+
79
+ api = FileAnalysis(
80
+ path=filepath,
81
+ language="python",
82
+ is_init=os.path.basename(filepath) == "__init__.py",
83
+ node_count=len(list(ast.walk(tree))),
84
+ )
85
+
86
+ # Module docstring
87
+ if (
88
+ tree.body
89
+ and isinstance(tree.body[0], ast.Expr)
90
+ and isinstance(tree.body[0].value, ast.Constant)
91
+ and isinstance(tree.body[0].value.value, str)
92
+ ):
93
+ api.docstring = tree.body[0].value.value
94
+
95
+ # Detect TYPE_CHECKING blocks
96
+ type_checking_lines = _find_type_checking_lines(tree)
97
+ all_decorators = set()
98
+
99
+ for node in ast.walk(tree):
100
+ if isinstance(node, ast.Import):
101
+ for alias in node.names:
102
+ top_module = alias.name.split(".")[0]
103
+ api.imports.append(ResolvedImport(
104
+ raw=alias.name,
105
+ module=top_module,
106
+ names=[alias.asname or alias.name],
107
+ is_relative=False,
108
+ is_conditional=_is_conditional(node, tree),
109
+ is_type_only=getattr(node, "lineno", 0) in type_checking_lines,
110
+ line=getattr(node, "lineno", 0),
111
+ ))
112
+ elif isinstance(node, ast.ImportFrom):
113
+ mod_str = node.module or ""
114
+ top_module = mod_str.split(".")[0] if mod_str else ""
115
+ is_star = any(a.name == "*" for a in node.names)
116
+ names = [a.name for a in node.names]
117
+ api.imports.append(ResolvedImport(
118
+ raw=f"{'.' * node.level}{mod_str}",
119
+ module=top_module,
120
+ names=names,
121
+ is_relative=node.level > 0,
122
+ is_star=is_star,
123
+ is_conditional=_is_conditional(node, tree),
124
+ is_type_only=getattr(node, "lineno", 0) in type_checking_lines,
125
+ line=getattr(node, "lineno", 0),
126
+ ))
127
+
128
+ for node in tree.body:
129
+ if isinstance(node, ast.ClassDef):
130
+ cls = _extract_class(node)
131
+ api.classes.append(cls)
132
+ all_decorators.update(cls.decorators)
133
+ api.exports.append(ExportedSymbol(
134
+ name=node.name, kind="class",
135
+ is_public=not node.name.startswith("_"),
136
+ ))
137
+
138
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
139
+ fn = _extract_function(node)
140
+ api.functions.append(fn)
141
+ all_decorators.update(fn.decorators)
142
+ api.exports.append(ExportedSymbol(
143
+ name=node.name, kind="function",
144
+ is_public=not node.name.startswith("_"),
145
+ ))
146
+
147
+ elif isinstance(node, ast.Assign):
148
+ for target in node.targets:
149
+ if isinstance(target, ast.Name):
150
+ if target.id == "__all__" and isinstance(node.value, (ast.List, ast.Tuple)):
151
+ api.all_list = [
152
+ elt.value for elt in node.value.elts
153
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
154
+ ]
155
+ if target.id.isupper() or not target.id.startswith("_"):
156
+ api.exports.append(ExportedSymbol(
157
+ name=target.id,
158
+ kind="constant" if target.id.isupper() else "variable",
159
+ is_public=not target.id.startswith("_"),
160
+ ))
161
+
162
+ elif isinstance(node, ast.If):
163
+ if _is_main_guard(node):
164
+ api.is_entry_point = True
165
+
166
+ api.decorators_used = sorted(all_decorators)
167
+
168
+ # Detect re-exports: names in __all__ that are also imported
169
+ if api.all_list:
170
+ imported_names = set()
171
+ for imp in api.imports:
172
+ imported_names.update(imp.names)
173
+ api.reexports = [n for n in api.all_list if n in imported_names]
174
+
175
+ return api
176
+
177
+
178
+ def _extract_class(node: ast.ClassDef) -> ClassInfo:
179
+ """Extract class definition details."""
180
+ bases = []
181
+ for base in node.bases:
182
+ if isinstance(base, ast.Name):
183
+ bases.append(base.id)
184
+ elif isinstance(base, ast.Attribute):
185
+ bases.append(ast.unparse(base))
186
+
187
+ methods = []
188
+ for item in node.body:
189
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
190
+ methods.append(item.name)
191
+
192
+ decorators = [_decorator_name(d) for d in node.decorator_list]
193
+ is_abstract = any("abstract" in d.lower() for d in decorators) or any(
194
+ "ABC" in b for b in bases
195
+ )
196
+
197
+ return ClassInfo(
198
+ name=node.name,
199
+ bases=bases,
200
+ methods=methods,
201
+ method_count=len(methods),
202
+ decorators=decorators,
203
+ is_abstract=is_abstract,
204
+ is_public=not node.name.startswith("_"),
205
+ line=getattr(node, "lineno", 0),
206
+ )
207
+
208
+
209
+ def _extract_function(node) -> FunctionInfo:
210
+ """Extract function definition details."""
211
+ params = []
212
+ for arg in node.args.args:
213
+ param = arg.arg
214
+ if arg.annotation:
215
+ try:
216
+ param += f": {ast.unparse(arg.annotation)}"
217
+ except Exception:
218
+ pass
219
+ params.append(param)
220
+
221
+ return_type = None
222
+ if node.returns:
223
+ try:
224
+ return_type = ast.unparse(node.returns)
225
+ except Exception:
226
+ pass
227
+
228
+ decorators = [_decorator_name(d) for d in node.decorator_list]
229
+
230
+ return FunctionInfo(
231
+ name=node.name,
232
+ params=params,
233
+ arg_count=len(node.args.args),
234
+ return_type=return_type,
235
+ decorators=decorators,
236
+ is_public=not node.name.startswith("_"),
237
+ is_async=isinstance(node, ast.AsyncFunctionDef),
238
+ complexity=_compute_complexity(node),
239
+ line=getattr(node, "lineno", 0),
240
+ )
241
+
242
+
243
+ def _decorator_name(node) -> str:
244
+ """Extract decorator name as string."""
245
+ try:
246
+ return ast.unparse(node)
247
+ except Exception:
248
+ if isinstance(node, ast.Name):
249
+ return node.id
250
+ elif isinstance(node, ast.Attribute):
251
+ return f"{ast.unparse(node)}"
252
+ return "unknown"
253
+
254
+
255
+ def _find_type_checking_lines(tree) -> set:
256
+ """Find line numbers inside TYPE_CHECKING blocks."""
257
+ lines = set()
258
+ for node in ast.walk(tree):
259
+ if isinstance(node, ast.If):
260
+ test = node.test
261
+ is_tc = False
262
+ if isinstance(test, ast.Name) and test.id == "TYPE_CHECKING":
263
+ is_tc = True
264
+ elif isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING":
265
+ is_tc = True
266
+ if is_tc:
267
+ for child in ast.walk(node):
268
+ if hasattr(child, "lineno"):
269
+ lines.add(child.lineno)
270
+ return lines
271
+
272
+
273
+ def _compute_complexity(node) -> int:
274
+ """Count branching nodes in a function body as complexity proxy."""
275
+ count = 0
276
+ for child in ast.walk(node):
277
+ if isinstance(child, (ast.If, ast.For, ast.While, ast.Try,
278
+ ast.ExceptHandler, ast.With, ast.Assert)):
279
+ count += 1
280
+ return count
281
+
282
+
283
+ def _is_conditional(node, tree) -> bool:
284
+ """Check if an import is inside a try/except or if block."""
285
+ for parent in ast.walk(tree):
286
+ for attr in ("body", "handlers", "orelse", "finalbody"):
287
+ children = getattr(parent, attr, None)
288
+ if isinstance(children, list) and node in children:
289
+ if isinstance(parent, (ast.Try, ast.If, ast.ExceptHandler)):
290
+ return True
291
+ return False
292
+
293
+
294
+ def _is_main_guard(node: ast.If) -> bool:
295
+ """Check if this is `if __name__ == '__main__'`."""
296
+ test = node.test
297
+ if isinstance(test, ast.Compare):
298
+ if (
299
+ isinstance(test.left, ast.Name)
300
+ and test.left.id == "__name__"
301
+ and len(test.comparators) == 1
302
+ ):
303
+ comp = test.comparators[0]
304
+ if isinstance(comp, ast.Constant) and comp.value == "__main__":
305
+ return True
306
+ return False
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # JS/TS analysis (enhanced regex)
311
+ # ---------------------------------------------------------------------------
312
+
313
+ def _analyze_js(filepath: str, source: str) -> Optional[FileAnalysis]:
314
+ """Enhanced regex-based JS/TS analysis."""
315
+ api = FileAnalysis(path=filepath, language="javascript")
316
+
317
+ for line in source.splitlines():
318
+ stripped = line.strip()
319
+
320
+ # import X from '...'
321
+ m = re.match(r"""import\s+(\w+)\s+from\s+['"]([^'"]+)['"]""", stripped)
322
+ if m:
323
+ api.imports.append(ResolvedImport(
324
+ raw=m.group(2), names=[m.group(1)],
325
+ is_relative=m.group(2).startswith("."),
326
+ ))
327
+ continue
328
+
329
+ # import { X, Y } from '...'
330
+ m = re.match(r"""import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]""", stripped)
331
+ if m:
332
+ names = [n.strip().split(" as ")[0].strip() for n in m.group(1).split(",")]
333
+ api.imports.append(ResolvedImport(
334
+ raw=m.group(2), names=names,
335
+ is_relative=m.group(2).startswith("."),
336
+ ))
337
+ continue
338
+
339
+ # import * as X from '...'
340
+ m = re.match(r"""import\s+\*\s+as\s+(\w+)\s+from\s+['"]([^'"]+)['"]""", stripped)
341
+ if m:
342
+ api.imports.append(ResolvedImport(
343
+ raw=m.group(2), names=[m.group(1)],
344
+ is_relative=m.group(2).startswith("."), is_star=True,
345
+ ))
346
+ continue
347
+
348
+ # require('...')
349
+ m = re.search(r"""require\(\s*['"]([^'"]+)['"]\s*\)""", stripped)
350
+ if m:
351
+ api.imports.append(ResolvedImport(
352
+ raw=m.group(1), names=[],
353
+ is_relative=m.group(1).startswith("."),
354
+ ))
355
+ continue
356
+
357
+ # Dynamic import('...')
358
+ m = re.search(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", stripped)
359
+ if m:
360
+ api.imports.append(ResolvedImport(
361
+ raw=m.group(1), names=[],
362
+ is_relative=m.group(1).startswith("."),
363
+ is_conditional=True, # dynamic = conditional
364
+ ))
365
+
366
+ # export default function/class X
367
+ m = re.match(r"export\s+default\s+(function|class)\s+(\w+)", stripped)
368
+ if m:
369
+ kind = m.group(1)
370
+ api.exports.append(ExportedSymbol(name=m.group(2), kind=kind, is_public=True))
371
+ if kind == "class":
372
+ api.classes.append(ClassInfo(name=m.group(2), is_public=True))
373
+ else:
374
+ api.functions.append(FunctionInfo(name=m.group(2), is_public=True))
375
+ continue
376
+
377
+ # export function/class X
378
+ m = re.match(r"export\s+(function|class)\s+(\w+)", stripped)
379
+ if m:
380
+ kind = m.group(1)
381
+ api.exports.append(ExportedSymbol(name=m.group(2), kind=kind, is_public=True))
382
+ if kind == "class":
383
+ api.classes.append(ClassInfo(name=m.group(2), is_public=True))
384
+ else:
385
+ api.functions.append(FunctionInfo(name=m.group(2), is_public=True))
386
+ continue
387
+
388
+ # export const/let/var X
389
+ m = re.match(r"export\s+(?:const|let|var)\s+(\w+)", stripped)
390
+ if m:
391
+ api.exports.append(ExportedSymbol(name=m.group(1), kind="variable", is_public=True))
392
+
393
+ return api
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # Go analysis (enhanced regex)
398
+ # ---------------------------------------------------------------------------
399
+
400
+ def _analyze_go(filepath: str, source: str) -> Optional[FileAnalysis]:
401
+ """Enhanced regex-based Go analysis."""
402
+ api = FileAnalysis(path=filepath, language="go")
403
+
404
+ in_import_block = False
405
+ for line in source.splitlines():
406
+ stripped = line.strip()
407
+
408
+ # Imports
409
+ if stripped == "import (":
410
+ in_import_block = True
411
+ continue
412
+ if in_import_block and stripped == ")":
413
+ in_import_block = False
414
+ continue
415
+ if in_import_block:
416
+ m = re.match(r'(?:\w+\s+)?"([^"]+)"', stripped)
417
+ if m:
418
+ api.imports.append(ResolvedImport(raw=m.group(1), names=[]))
419
+ continue
420
+ m = re.match(r'import\s+"([^"]+)"', stripped)
421
+ if m:
422
+ api.imports.append(ResolvedImport(raw=m.group(1), names=[]))
423
+ continue
424
+
425
+ # Exported functions (capitalized)
426
+ m = re.match(r"func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)\s*\(", stripped)
427
+ if m:
428
+ name = m.group(1)
429
+ is_public = name[0].isupper()
430
+ api.functions.append(FunctionInfo(name=name, is_public=is_public))
431
+ if is_public:
432
+ api.exports.append(ExportedSymbol(name=name, kind="function", is_public=True))
433
+
434
+ # Exported types
435
+ m = re.match(r"type\s+(\w+)\s+(struct|interface)", stripped)
436
+ if m:
437
+ name = m.group(1)
438
+ is_public = name[0].isupper()
439
+ api.classes.append(ClassInfo(name=name, is_public=is_public))
440
+ if is_public:
441
+ api.exports.append(ExportedSymbol(name=name, kind="class", is_public=True))
442
+
443
+ return api
444
+
445
+
446
+ # ---------------------------------------------------------------------------
447
+ # Import resolution
448
+ # ---------------------------------------------------------------------------
449
+
450
+ def resolve_python_import(imp: ResolvedImport, source_file: str, root: str) -> Optional[str]:
451
+ """Resolve a Python import to a file path relative to root."""
452
+ if imp.is_relative:
453
+ source_dir = os.path.dirname(source_file)
454
+ # Count dots: from .. = current dir, from .. = parent, etc.
455
+ dots = 0
456
+ raw = imp.raw
457
+ while raw.startswith("."):
458
+ dots += 1
459
+ raw = raw[1:]
460
+
461
+ base = source_dir
462
+ for _ in range(dots - 1):
463
+ base = os.path.dirname(base)
464
+
465
+ if raw:
466
+ parts = raw.split(".")
467
+ candidate_base = os.path.join(base, *parts)
468
+ else:
469
+ candidate_base = base
470
+
471
+ return _find_python_module(candidate_base, root)
472
+
473
+ # Absolute import
474
+ parts = imp.raw.split(".")
475
+ candidate_base = os.path.join(root, *parts)
476
+ return _find_python_module(candidate_base, root)
477
+
478
+
479
+ def _find_python_module(candidate_base: str, root: str) -> Optional[str]:
480
+ """Try to resolve a Python module path to an actual file."""
481
+ candidates = [
482
+ candidate_base + ".py",
483
+ os.path.join(candidate_base, "__init__.py"),
484
+ ]
485
+ for c in candidates:
486
+ if os.path.isfile(c):
487
+ return os.path.relpath(c, root)
488
+ return None
489
+
490
+
491
+ def resolve_js_import(imp: ResolvedImport, source_file: str, root: str) -> Optional[str]:
492
+ """Resolve a JS/TS import to a file path relative to root."""
493
+ if not imp.raw.startswith("."):
494
+ return None # Bare module (npm package)
495
+
496
+ source_dir = os.path.dirname(source_file)
497
+ base = os.path.normpath(os.path.join(source_dir, imp.raw))
498
+ rel_base = os.path.relpath(base, root)
499
+
500
+ exts = [".ts", ".tsx", ".js", ".jsx"]
501
+ for ext in exts:
502
+ if os.path.isfile(os.path.join(root, rel_base + ext)):
503
+ return rel_base + ext
504
+ for idx in ["index.ts", "index.tsx", "index.js", "index.jsx"]:
505
+ candidate = os.path.join(rel_base, idx)
506
+ if os.path.isfile(os.path.join(root, candidate)):
507
+ return candidate
508
+ return None