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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Convention drift detection: check modified files against conventions."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
|
|
5
|
+
from ..models import CheckCategory, CheckResult, ConventionRule, Severity
|
|
6
|
+
from ....models.core import FileAnalysis
|
|
7
|
+
from ...convention_compliance import convention_severity
|
|
8
|
+
from ...convention_parser import check_convention_rules, matches_convention
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_conventions(
|
|
12
|
+
modified_files: List[str],
|
|
13
|
+
added_lines: Dict[str, List[str]],
|
|
14
|
+
conventions: List[ConventionRule],
|
|
15
|
+
ast_data: Dict[str, FileAnalysis],
|
|
16
|
+
) -> List[CheckResult]:
|
|
17
|
+
"""Check modified files for convention drift.
|
|
18
|
+
|
|
19
|
+
Returns HOLDs for conventions with >=80% compliance,
|
|
20
|
+
NOTEs for 50-79%, and skips retired conventions.
|
|
21
|
+
"""
|
|
22
|
+
results = []
|
|
23
|
+
|
|
24
|
+
for filepath in modified_files:
|
|
25
|
+
analysis = ast_data.get(filepath)
|
|
26
|
+
if not analysis:
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
for rule in conventions:
|
|
30
|
+
severity = convention_severity(rule.compliance)
|
|
31
|
+
if severity == "retired":
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
if matches_convention(analysis, filepath, rule.match_criteria):
|
|
35
|
+
violations = check_convention_rules(analysis, filepath, rule.rules)
|
|
36
|
+
for v in violations:
|
|
37
|
+
results.append(CheckResult(
|
|
38
|
+
passed=False,
|
|
39
|
+
category=CheckCategory.CONVENTION,
|
|
40
|
+
severity=(
|
|
41
|
+
Severity.NUDGE if severity in ("hold", "nudge")
|
|
42
|
+
else Severity.NOTE
|
|
43
|
+
),
|
|
44
|
+
message=f"Convention drift: {rule.name}",
|
|
45
|
+
detail=(
|
|
46
|
+
f"{filepath} is recognized as a '{rule.name}'.\n"
|
|
47
|
+
f"Violation: {v}\n"
|
|
48
|
+
f"Convention compliance: {rule.compliance:.0%}"
|
|
49
|
+
),
|
|
50
|
+
file=filepath,
|
|
51
|
+
suggestion=rule.description,
|
|
52
|
+
))
|
|
53
|
+
|
|
54
|
+
return results
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Dependency direction check: new imports that reverse established flow."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from ..models import CheckCategory, CheckResult, Severity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_dependency_direction(
|
|
10
|
+
added_lines: Dict[str, List[str]],
|
|
11
|
+
graph_hubs: Dict[str, object],
|
|
12
|
+
scopes: Dict[str, dict],
|
|
13
|
+
) -> List[CheckResult]:
|
|
14
|
+
"""Detect new imports that reverse established dependency direction.
|
|
15
|
+
|
|
16
|
+
If models/ is imported BY api/ (not the other way around),
|
|
17
|
+
a new 'from api import ...' in models/ is a direction reversal.
|
|
18
|
+
"""
|
|
19
|
+
results = []
|
|
20
|
+
|
|
21
|
+
# Build direction map from graph hubs: module → set of modules it imports
|
|
22
|
+
import_directions = {} # type: Dict[str, set]
|
|
23
|
+
for hub_file, hub_data in graph_hubs.items():
|
|
24
|
+
if isinstance(hub_data, dict):
|
|
25
|
+
imported_by = hub_data.get("imported_by", [])
|
|
26
|
+
module = hub_file.split("/")[0] if "/" in hub_file else ""
|
|
27
|
+
if module:
|
|
28
|
+
for importer in imported_by:
|
|
29
|
+
imp_module = importer.split("/")[0] if "/" in importer else ""
|
|
30
|
+
if imp_module and imp_module != module:
|
|
31
|
+
import_directions.setdefault(imp_module, set()).add(module)
|
|
32
|
+
|
|
33
|
+
if not import_directions:
|
|
34
|
+
return results
|
|
35
|
+
|
|
36
|
+
import_re = re.compile(
|
|
37
|
+
r'(?:from\s+(\S+)\s+import|import\s+(\S+))'
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
for filepath, lines in added_lines.items():
|
|
41
|
+
file_module = filepath.split("/")[0] if "/" in filepath else ""
|
|
42
|
+
if not file_module:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
for line_text in lines:
|
|
46
|
+
m = import_re.search(line_text)
|
|
47
|
+
if not m:
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
imported = m.group(1) or m.group(2) or ""
|
|
51
|
+
imported_module = imported.split(".")[0]
|
|
52
|
+
|
|
53
|
+
if not imported_module or imported_module == file_module:
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
# Check: does imported_module normally import file_module?
|
|
57
|
+
# If so, this new import reverses the established direction.
|
|
58
|
+
if file_module in import_directions.get(imported_module, set()):
|
|
59
|
+
results.append(CheckResult(
|
|
60
|
+
passed=False,
|
|
61
|
+
category=CheckCategory.DIRECTION,
|
|
62
|
+
severity=Severity.NOTE,
|
|
63
|
+
message=(
|
|
64
|
+
f"New import in {filepath}: {imported_module} "
|
|
65
|
+
f"is imported BY {file_module}, not the other way around"
|
|
66
|
+
),
|
|
67
|
+
detail=f"{file_module}/ normally does not import from {imported_module}/",
|
|
68
|
+
file=filepath,
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
return results
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Architectural intent checks: validate changes against declared direction."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Dict, List
|
|
5
|
+
|
|
6
|
+
from ..models import CheckCategory, CheckResult, ProposedFix, Severity, IntentDirective
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_intent_holds(
|
|
10
|
+
modified_files: List[str],
|
|
11
|
+
added_lines: Dict[str, List[str]],
|
|
12
|
+
intents: List[IntentDirective],
|
|
13
|
+
) -> List[CheckResult]:
|
|
14
|
+
"""Check for HOLD-level intent violations (deprecate, freeze)."""
|
|
15
|
+
results = []
|
|
16
|
+
|
|
17
|
+
for intent in intents:
|
|
18
|
+
if intent.directive == "deprecate":
|
|
19
|
+
results.extend(_check_deprecate(added_lines, intent))
|
|
20
|
+
elif intent.directive == "freeze":
|
|
21
|
+
results.extend(_check_freeze(modified_files, intent))
|
|
22
|
+
|
|
23
|
+
return results
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_intent_notes(
|
|
27
|
+
modified_files: List[str],
|
|
28
|
+
added_lines: Dict[str, List[str]],
|
|
29
|
+
intents: List[IntentDirective],
|
|
30
|
+
) -> List[CheckResult]:
|
|
31
|
+
"""Check for NOTE-level intent violations (decouple, consolidate)."""
|
|
32
|
+
results = []
|
|
33
|
+
|
|
34
|
+
for intent in intents:
|
|
35
|
+
if intent.directive == "decouple":
|
|
36
|
+
results.extend(_check_decouple(added_lines, intent))
|
|
37
|
+
elif intent.directive == "consolidate":
|
|
38
|
+
results.extend(_check_consolidate(modified_files, intent))
|
|
39
|
+
|
|
40
|
+
return results
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _check_deprecate(
|
|
44
|
+
added_lines: Dict[str, List[str]],
|
|
45
|
+
intent: IntentDirective,
|
|
46
|
+
) -> List[CheckResult]:
|
|
47
|
+
"""New usage of deprecated files is a HOLD."""
|
|
48
|
+
results = []
|
|
49
|
+
deprecated = set(intent.files)
|
|
50
|
+
|
|
51
|
+
import_re = re.compile(
|
|
52
|
+
r'(?:from\s+(\S+)\s+import|import\s+(\S+))'
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
from ..line_filter import strip_comments_and_strings
|
|
56
|
+
|
|
57
|
+
for filepath, lines in added_lines.items():
|
|
58
|
+
# Don't flag the deprecated file itself
|
|
59
|
+
if filepath in deprecated:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
for line_text in lines:
|
|
63
|
+
code_only = strip_comments_and_strings(line_text)
|
|
64
|
+
if not code_only.strip():
|
|
65
|
+
continue
|
|
66
|
+
m = import_re.search(code_only)
|
|
67
|
+
if not m:
|
|
68
|
+
continue
|
|
69
|
+
imported = (m.group(1) or m.group(2) or "").replace(".", "/")
|
|
70
|
+
for dep_file in deprecated:
|
|
71
|
+
dep_module = dep_file.replace(".py", "").replace("/", ".")
|
|
72
|
+
dep_path = dep_file.replace(".py", "")
|
|
73
|
+
if dep_module in (m.group(1) or "") or dep_path in imported:
|
|
74
|
+
fix = None
|
|
75
|
+
if intent.replacement:
|
|
76
|
+
fix = ProposedFix(
|
|
77
|
+
file=intent.replacement,
|
|
78
|
+
reason=f"Use {intent.replacement} instead of {dep_file}",
|
|
79
|
+
confidence=1.0,
|
|
80
|
+
)
|
|
81
|
+
results.append(CheckResult(
|
|
82
|
+
passed=False,
|
|
83
|
+
category=CheckCategory.INTENT,
|
|
84
|
+
severity=Severity.GUARD,
|
|
85
|
+
message=f"{dep_file} is deprecated: {intent.reason}",
|
|
86
|
+
detail=f"New import in {filepath}",
|
|
87
|
+
file=filepath,
|
|
88
|
+
suggestion=f"Use {intent.replacement}" if intent.replacement else "Remove usage",
|
|
89
|
+
proposed_fix=fix,
|
|
90
|
+
can_acknowledge=True,
|
|
91
|
+
acknowledge_id=f"intent_deprecate_{intent.id}",
|
|
92
|
+
))
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
return results
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_freeze(
|
|
99
|
+
modified_files: List[str],
|
|
100
|
+
intent: IntentDirective,
|
|
101
|
+
) -> List[CheckResult]:
|
|
102
|
+
"""Any change to frozen modules is a GUARD."""
|
|
103
|
+
results = []
|
|
104
|
+
frozen = set(intent.modules)
|
|
105
|
+
|
|
106
|
+
for filepath in modified_files:
|
|
107
|
+
for module in frozen:
|
|
108
|
+
if filepath.startswith(module):
|
|
109
|
+
results.append(CheckResult(
|
|
110
|
+
passed=False,
|
|
111
|
+
category=CheckCategory.INTENT,
|
|
112
|
+
severity=Severity.GUARD,
|
|
113
|
+
message=f"{module} is frozen: {intent.reason}",
|
|
114
|
+
detail=f"Modified {filepath}",
|
|
115
|
+
file=filepath,
|
|
116
|
+
suggestion="Requires explicit acknowledgment to proceed",
|
|
117
|
+
can_acknowledge=True,
|
|
118
|
+
acknowledge_id=f"intent_freeze_{intent.id}",
|
|
119
|
+
))
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
return results
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _check_decouple(
|
|
126
|
+
added_lines: Dict[str, List[str]],
|
|
127
|
+
intent: IntentDirective,
|
|
128
|
+
) -> List[CheckResult]:
|
|
129
|
+
"""New coupling between decoupling-targeted modules is a NOTE."""
|
|
130
|
+
results = []
|
|
131
|
+
if len(intent.modules) < 2:
|
|
132
|
+
return results
|
|
133
|
+
|
|
134
|
+
modules = intent.modules
|
|
135
|
+
import_re = re.compile(
|
|
136
|
+
r'(?:from\s+(\S+)\s+import|import\s+(\S+))'
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
from ..line_filter import strip_comments_and_strings
|
|
140
|
+
|
|
141
|
+
for filepath, lines in added_lines.items():
|
|
142
|
+
file_module = _file_to_module(filepath)
|
|
143
|
+
if file_module not in modules:
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
for line_text in lines:
|
|
147
|
+
code_only = strip_comments_and_strings(line_text)
|
|
148
|
+
if not code_only.strip():
|
|
149
|
+
continue
|
|
150
|
+
m = import_re.search(code_only)
|
|
151
|
+
if not m:
|
|
152
|
+
continue
|
|
153
|
+
imported = (m.group(1) or m.group(2) or "").split(".")[0]
|
|
154
|
+
imported_mod = imported + "/"
|
|
155
|
+
if imported_mod in modules and imported_mod != file_module:
|
|
156
|
+
results.append(CheckResult(
|
|
157
|
+
passed=False,
|
|
158
|
+
category=CheckCategory.INTENT,
|
|
159
|
+
severity=Severity.NOTE,
|
|
160
|
+
message=(
|
|
161
|
+
f"New coupling: {file_module} -> {imported_mod} "
|
|
162
|
+
f"(intent: decouple, set {intent.set_at})"
|
|
163
|
+
),
|
|
164
|
+
detail=intent.reason,
|
|
165
|
+
file=filepath,
|
|
166
|
+
))
|
|
167
|
+
break # One per file
|
|
168
|
+
|
|
169
|
+
return results
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _check_consolidate(
|
|
173
|
+
modified_files: List[str],
|
|
174
|
+
intent: IntentDirective,
|
|
175
|
+
) -> List[CheckResult]:
|
|
176
|
+
"""Code moving away from consolidation target is a NOTE."""
|
|
177
|
+
results = []
|
|
178
|
+
if not intent.target or len(intent.modules) < 1:
|
|
179
|
+
return results
|
|
180
|
+
|
|
181
|
+
target = intent.target
|
|
182
|
+
source_modules = [m for m in intent.modules if m != target]
|
|
183
|
+
|
|
184
|
+
for filepath in modified_files:
|
|
185
|
+
for mod in source_modules:
|
|
186
|
+
if filepath.startswith(mod):
|
|
187
|
+
# A change to a source module (not moving toward target) is a note
|
|
188
|
+
results.append(CheckResult(
|
|
189
|
+
passed=False,
|
|
190
|
+
category=CheckCategory.INTENT,
|
|
191
|
+
severity=Severity.NOTE,
|
|
192
|
+
message=(
|
|
193
|
+
f"Changes to {mod}, intent is to consolidate into {target} "
|
|
194
|
+
f"(set {intent.set_at})"
|
|
195
|
+
),
|
|
196
|
+
detail=intent.reason,
|
|
197
|
+
file=filepath,
|
|
198
|
+
))
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
return results
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _file_to_module(filepath: str) -> str:
|
|
205
|
+
"""Extract the top-level module directory from a file path."""
|
|
206
|
+
parts = filepath.split("/")
|
|
207
|
+
return parts[0] + "/" if len(parts) > 1 else ""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Stability concern check: large changes to stable files."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
|
|
5
|
+
from ..models import CheckCategory, CheckResult, Severity
|
|
6
|
+
|
|
7
|
+
# Threshold: a file is "stable" and a change is "large" above this
|
|
8
|
+
LARGE_CHANGE_LINES = 20
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_stability(
|
|
12
|
+
modified_files: List[str],
|
|
13
|
+
diff_text: str,
|
|
14
|
+
invariants: dict,
|
|
15
|
+
) -> List[CheckResult]:
|
|
16
|
+
"""Flag large changes to files classified as stable."""
|
|
17
|
+
stabilities = invariants.get("file_stabilities", {})
|
|
18
|
+
if not stabilities:
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
# Count added lines per file in the diff
|
|
22
|
+
file_additions = _count_additions(diff_text)
|
|
23
|
+
results = []
|
|
24
|
+
|
|
25
|
+
for filepath in modified_files:
|
|
26
|
+
info = stabilities.get(filepath)
|
|
27
|
+
if not info:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
classification = info.get("classification", "")
|
|
31
|
+
if classification != "stable":
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
additions = file_additions.get(filepath, 0)
|
|
35
|
+
if additions < LARGE_CHANGE_LINES:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
commit_count = info.get("commit_count", 0)
|
|
39
|
+
results.append(CheckResult(
|
|
40
|
+
passed=False,
|
|
41
|
+
category=CheckCategory.STABILITY,
|
|
42
|
+
severity=Severity.NOTE,
|
|
43
|
+
message=(
|
|
44
|
+
f"{filepath}: stable file ({commit_count} commits), "
|
|
45
|
+
f"this diff changes {additions} lines"
|
|
46
|
+
),
|
|
47
|
+
detail="Large changes to stable files deserve extra review",
|
|
48
|
+
file=filepath,
|
|
49
|
+
))
|
|
50
|
+
|
|
51
|
+
return results
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _count_additions(diff_text: str) -> Dict[str, int]:
|
|
55
|
+
"""Count added lines per file in a unified diff."""
|
|
56
|
+
counts: Dict[str, int] = {}
|
|
57
|
+
current_file = ""
|
|
58
|
+
|
|
59
|
+
for line in diff_text.splitlines():
|
|
60
|
+
if line.startswith("diff --git"):
|
|
61
|
+
parts = line.split(" b/", 1)
|
|
62
|
+
current_file = parts[1] if len(parts) > 1 else ""
|
|
63
|
+
elif line.startswith("+") and not line.startswith("+++") and current_file:
|
|
64
|
+
counts[current_file] = counts.get(current_file, 0) + 1
|
|
65
|
+
|
|
66
|
+
return counts
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Voice check: bare excepts and missing type hints on new functions."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import os
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ..models import CheckCategory, CheckResult, Severity
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_voice(
|
|
11
|
+
modified_files: List[str],
|
|
12
|
+
added_lines: Dict[str, List[str]],
|
|
13
|
+
voice_config: Optional[dict],
|
|
14
|
+
repo_root: str,
|
|
15
|
+
) -> List[CheckResult]:
|
|
16
|
+
"""Mechanical voice checks. Only fires for rules with enforce != false."""
|
|
17
|
+
if not voice_config:
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
enforce = voice_config.get("enforce", {})
|
|
21
|
+
if not enforce:
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
results = []
|
|
25
|
+
|
|
26
|
+
for filepath in modified_files:
|
|
27
|
+
if not filepath.endswith(".py"):
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
full_path = os.path.join(repo_root, filepath)
|
|
31
|
+
if not os.path.isfile(full_path):
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
with open(full_path, "r", encoding="utf-8") as f:
|
|
36
|
+
source = f.read()
|
|
37
|
+
tree = ast.parse(source)
|
|
38
|
+
except (SyntaxError, IOError, UnicodeDecodeError):
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
# Bare excepts
|
|
42
|
+
bare_level = enforce.get("bare_excepts")
|
|
43
|
+
if bare_level and bare_level is not False:
|
|
44
|
+
severity = Severity.NUDGE if bare_level == "hold" else Severity.NOTE
|
|
45
|
+
for node in ast.walk(tree):
|
|
46
|
+
if isinstance(node, ast.ExceptHandler) and node.type is None:
|
|
47
|
+
results.append(CheckResult(
|
|
48
|
+
passed=False,
|
|
49
|
+
category=CheckCategory.VOICE,
|
|
50
|
+
severity=severity,
|
|
51
|
+
message=f"Bare except in {filepath}:{node.lineno}",
|
|
52
|
+
detail="Catch a specific exception type.",
|
|
53
|
+
file=filepath,
|
|
54
|
+
suggestion="Replace `except:` with `except SpecificError:`",
|
|
55
|
+
))
|
|
56
|
+
|
|
57
|
+
# Missing type hints (only on new/modified functions)
|
|
58
|
+
hint_level = enforce.get("missing_type_hints")
|
|
59
|
+
if hint_level and hint_level is not False:
|
|
60
|
+
severity = Severity.NUDGE if hint_level == "hold" else Severity.NOTE
|
|
61
|
+
|
|
62
|
+
# Filter to code-only lines (exclude comments and strings)
|
|
63
|
+
from ..line_filter import strip_comments_and_strings
|
|
64
|
+
code_lines = []
|
|
65
|
+
for line in added_lines.get(filepath, []):
|
|
66
|
+
code = strip_comments_and_strings(line)
|
|
67
|
+
if code.strip():
|
|
68
|
+
code_lines.append(code)
|
|
69
|
+
|
|
70
|
+
for node in ast.walk(tree):
|
|
71
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# Check if this function definition appears in code-only added lines
|
|
75
|
+
func_line = f"def {node.name}("
|
|
76
|
+
async_line = f"async def {node.name}("
|
|
77
|
+
is_new = any(
|
|
78
|
+
func_line in line or async_line in line
|
|
79
|
+
for line in code_lines
|
|
80
|
+
)
|
|
81
|
+
if not is_new:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
# Skip dunder methods and test functions
|
|
85
|
+
if node.name.startswith("__") or node.name.startswith("test_"):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
has_hints = bool(node.returns)
|
|
89
|
+
if not has_hints:
|
|
90
|
+
# Check if any param has annotation (skip self/cls)
|
|
91
|
+
params = [
|
|
92
|
+
a for a in node.args.args
|
|
93
|
+
if a.arg not in ("self", "cls")
|
|
94
|
+
]
|
|
95
|
+
has_hints = any(a.annotation for a in params)
|
|
96
|
+
|
|
97
|
+
if not has_hints:
|
|
98
|
+
results.append(CheckResult(
|
|
99
|
+
passed=False,
|
|
100
|
+
category=CheckCategory.VOICE,
|
|
101
|
+
severity=severity,
|
|
102
|
+
message=f"Missing type hints: {filepath}:{node.name}()",
|
|
103
|
+
detail="Add type hints to function signature.",
|
|
104
|
+
file=filepath,
|
|
105
|
+
suggestion=f"Add type hints to {node.name}()",
|
|
106
|
+
))
|
|
107
|
+
|
|
108
|
+
return results
|