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.
- dotscope/.scope +63 -0
- dotscope/__init__.py +3 -0
- dotscope/absorber.py +390 -0
- dotscope/assertions.py +128 -0
- dotscope/ast_analyzer.py +2 -0
- dotscope/backtest.py +2 -0
- dotscope/bench.py +141 -0
- dotscope/budget.py +3 -0
- dotscope/cache.py +2 -0
- dotscope/check/__init__.py +1 -0
- dotscope/check/acknowledge.py +2 -0
- dotscope/check/checker.py +3 -0
- dotscope/check/checks/__init__.py +1 -0
- dotscope/check/checks/antipattern.py +2 -0
- dotscope/check/checks/boundary.py +2 -0
- dotscope/check/checks/contracts.py +3 -0
- dotscope/check/checks/direction.py +2 -0
- dotscope/check/checks/intent.py +2 -0
- dotscope/check/checks/stability.py +2 -0
- dotscope/check/constraints.py +2 -0
- dotscope/check/models.py +15 -0
- dotscope/cli.py +1447 -0
- dotscope/composer.py +147 -0
- dotscope/constants.py +45 -0
- dotscope/context.py +60 -0
- dotscope/counterfactual.py +180 -0
- dotscope/debug.py +220 -0
- dotscope/discovery.py +104 -0
- dotscope/formatter.py +157 -0
- dotscope/graph.py +3 -0
- dotscope/health.py +212 -0
- dotscope/help.py +204 -0
- dotscope/history.py +6 -0
- dotscope/hooks.py +2 -0
- dotscope/ingest.py +858 -0
- dotscope/intent.py +618 -0
- dotscope/lessons.py +223 -0
- dotscope/matcher.py +104 -0
- dotscope/mcp_server.py +1081 -0
- dotscope/models/.scope +45 -0
- dotscope/models/__init__.py +7 -0
- dotscope/models/core.py +288 -0
- dotscope/models/history.py +73 -0
- dotscope/models/intent.py +213 -0
- dotscope/models/passes.py +58 -0
- dotscope/models/state.py +250 -0
- dotscope/models.py +9 -0
- dotscope/near_miss.py +3 -0
- dotscope/onboarding.py +2 -0
- dotscope/parser.py +387 -0
- dotscope/passes/.scope +105 -0
- dotscope/passes/__init__.py +1 -0
- dotscope/passes/ast_analyzer.py +508 -0
- dotscope/passes/backtest.py +198 -0
- dotscope/passes/budget_allocator.py +164 -0
- dotscope/passes/convention_compliance.py +40 -0
- dotscope/passes/convention_discovery.py +247 -0
- dotscope/passes/convention_parser.py +223 -0
- dotscope/passes/graph_builder.py +299 -0
- dotscope/passes/history_miner.py +336 -0
- dotscope/passes/incremental.py +149 -0
- dotscope/passes/lang/__init__.py +38 -0
- dotscope/passes/lang/_base.py +20 -0
- dotscope/passes/lang/_treesitter.py +93 -0
- dotscope/passes/lang/go.py +333 -0
- dotscope/passes/lang/javascript.py +348 -0
- dotscope/passes/lazy.py +152 -0
- dotscope/passes/semantic_diff.py +160 -0
- dotscope/passes/sentinel/__init__.py +1 -0
- dotscope/passes/sentinel/acknowledge.py +222 -0
- dotscope/passes/sentinel/checker.py +383 -0
- dotscope/passes/sentinel/checks/__init__.py +1 -0
- dotscope/passes/sentinel/checks/antipattern.py +84 -0
- dotscope/passes/sentinel/checks/boundary.py +46 -0
- dotscope/passes/sentinel/checks/contracts.py +148 -0
- dotscope/passes/sentinel/checks/convention.py +54 -0
- dotscope/passes/sentinel/checks/direction.py +71 -0
- dotscope/passes/sentinel/checks/intent.py +207 -0
- dotscope/passes/sentinel/checks/stability.py +66 -0
- dotscope/passes/sentinel/checks/voice.py +108 -0
- dotscope/passes/sentinel/constraints.py +472 -0
- dotscope/passes/sentinel/line_filter.py +88 -0
- dotscope/passes/sentinel/models.py +15 -0
- dotscope/passes/virtual.py +239 -0
- dotscope/passes/voice.py +162 -0
- dotscope/passes/voice_defaults.py +28 -0
- dotscope/passes/voice_discovery.py +245 -0
- dotscope/paths.py +32 -0
- dotscope/progress.py +44 -0
- dotscope/regression.py +147 -0
- dotscope/resolver.py +203 -0
- dotscope/scanner.py +246 -0
- dotscope/sessions.py +2 -0
- dotscope/storage/.scope +64 -0
- dotscope/storage/__init__.py +1 -0
- dotscope/storage/cache.py +114 -0
- dotscope/storage/claude_hooks.py +119 -0
- dotscope/storage/git_hooks.py +277 -0
- dotscope/storage/incremental_state.py +61 -0
- dotscope/storage/mcp_config.py +98 -0
- dotscope/storage/near_miss.py +183 -0
- dotscope/storage/onboarding.py +150 -0
- dotscope/storage/session_manager.py +195 -0
- dotscope/storage/timing.py +84 -0
- dotscope/timing.py +2 -0
- dotscope/tokens.py +53 -0
- dotscope/utility.py +123 -0
- dotscope/virtual.py +3 -0
- dotscope/visibility.py +664 -0
- dotscope-0.1.0.dist-info/METADATA +50 -0
- dotscope-0.1.0.dist-info/RECORD +114 -0
- dotscope-0.1.0.dist-info/WHEEL +4 -0
- dotscope-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|