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,383 @@
|
|
|
1
|
+
"""Core check pipeline: run all checks against a diff."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from .models import CheckReport, CheckResult, Severity
|
|
11
|
+
from .checks.boundary import check_boundaries
|
|
12
|
+
from .checks.contracts import check_contracts
|
|
13
|
+
from .checks.antipattern import check_antipatterns
|
|
14
|
+
from .checks.direction import check_dependency_direction
|
|
15
|
+
from .checks.stability import check_stability
|
|
16
|
+
from .checks.convention import check_conventions
|
|
17
|
+
from .checks.intent import check_intent_holds, check_intent_notes
|
|
18
|
+
from .checks.voice import check_voice
|
|
19
|
+
from .acknowledge import is_acknowledged
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_diff(
|
|
23
|
+
diff_text: str,
|
|
24
|
+
repo_root: str,
|
|
25
|
+
session_id: Optional[str] = None,
|
|
26
|
+
acknowledge_ids: Optional[List[str]] = None,
|
|
27
|
+
) -> CheckReport:
|
|
28
|
+
"""Run all checks against a diff.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
diff_text: Unified diff text
|
|
32
|
+
repo_root: Repository root path
|
|
33
|
+
session_id: Optional session ID for boundary checking
|
|
34
|
+
acknowledge_ids: IDs to treat as pre-acknowledged
|
|
35
|
+
"""
|
|
36
|
+
modified_files, added_lines = _parse_diff(diff_text)
|
|
37
|
+
|
|
38
|
+
if not modified_files:
|
|
39
|
+
return CheckReport(passed=True, files_checked=0, checks_run=0)
|
|
40
|
+
|
|
41
|
+
# Cap enormous diffs (with warning)
|
|
42
|
+
capped = False
|
|
43
|
+
total_files = len(modified_files)
|
|
44
|
+
if total_files > 100:
|
|
45
|
+
modified_files = modified_files[:100]
|
|
46
|
+
capped = True
|
|
47
|
+
|
|
48
|
+
# Load all data
|
|
49
|
+
invariants = _load_invariants(repo_root)
|
|
50
|
+
scopes = _load_scopes_with_antipatterns(repo_root)
|
|
51
|
+
graph_hubs = _load_graph_hubs(repo_root)
|
|
52
|
+
session = _resolve_session(repo_root, session_id)
|
|
53
|
+
intents = _load_intents(repo_root)
|
|
54
|
+
conventions, convention_ast = _load_conventions_and_ast(repo_root, modified_files)
|
|
55
|
+
voice_config = _load_voice_config(repo_root)
|
|
56
|
+
|
|
57
|
+
results: List[CheckResult] = []
|
|
58
|
+
|
|
59
|
+
# HOLDs
|
|
60
|
+
results.extend(check_boundaries(modified_files, session, scopes))
|
|
61
|
+
results.extend(check_contracts(modified_files, invariants, diff_text))
|
|
62
|
+
results.extend(check_antipatterns(added_lines, scopes, repo_root))
|
|
63
|
+
results.extend(check_intent_holds(modified_files, added_lines, intents))
|
|
64
|
+
results.extend(check_conventions(modified_files, added_lines, conventions, convention_ast))
|
|
65
|
+
results.extend(check_voice(modified_files, added_lines, voice_config, repo_root))
|
|
66
|
+
|
|
67
|
+
# NOTEs
|
|
68
|
+
results.extend(check_dependency_direction(added_lines, graph_hubs, scopes))
|
|
69
|
+
results.extend(check_stability(modified_files, diff_text, invariants))
|
|
70
|
+
results.extend(check_intent_notes(modified_files, added_lines, intents))
|
|
71
|
+
|
|
72
|
+
# Warn if files were capped
|
|
73
|
+
if capped:
|
|
74
|
+
results.append(CheckResult(
|
|
75
|
+
passed=False,
|
|
76
|
+
category=CheckCategory.STABILITY,
|
|
77
|
+
severity=Severity.NOTE,
|
|
78
|
+
message=f"Large diff: checked 100 of {total_files} files",
|
|
79
|
+
detail=f"Files beyond position 100 were not checked.",
|
|
80
|
+
file=None,
|
|
81
|
+
))
|
|
82
|
+
|
|
83
|
+
# Gap 3: NUDGE escalation — repeated nudges become guards
|
|
84
|
+
from .acknowledge import (
|
|
85
|
+
record_nudge_occurrence, record_nudge_resolution, is_escalated,
|
|
86
|
+
)
|
|
87
|
+
# Track which nudge IDs fired this run
|
|
88
|
+
fired_nudge_ids = set()
|
|
89
|
+
for r in results:
|
|
90
|
+
if r.severity == Severity.NUDGE and not r.passed and r.acknowledge_id:
|
|
91
|
+
fired_nudge_ids.add(r.acknowledge_id)
|
|
92
|
+
record_nudge_occurrence(repo_root, r.acknowledge_id)
|
|
93
|
+
if is_escalated(repo_root, r.acknowledge_id):
|
|
94
|
+
r.severity = Severity.GUARD
|
|
95
|
+
|
|
96
|
+
# Record resolutions: nudges that were previously tracked but didn't fire
|
|
97
|
+
# this run have been fixed — reset their escalation counter
|
|
98
|
+
try:
|
|
99
|
+
nudge_path = os.path.join(repo_root, ".dotscope", "nudge_occurrences.jsonl")
|
|
100
|
+
if os.path.exists(nudge_path):
|
|
101
|
+
known_ids: set = set()
|
|
102
|
+
with open(nudge_path, "r", encoding="utf-8") as _f:
|
|
103
|
+
for _line in _f:
|
|
104
|
+
_line = _line.strip()
|
|
105
|
+
if _line:
|
|
106
|
+
try:
|
|
107
|
+
_entry = json.loads(_line)
|
|
108
|
+
if not _entry.get("resolved"):
|
|
109
|
+
known_ids.add(_entry.get("id", ""))
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
pass
|
|
112
|
+
for kid in known_ids:
|
|
113
|
+
if kid and kid not in fired_nudge_ids:
|
|
114
|
+
record_nudge_resolution(repo_root, kid)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
# Filter acknowledged
|
|
119
|
+
ack_set = set(acknowledge_ids or [])
|
|
120
|
+
for r in results:
|
|
121
|
+
if r.acknowledge_id and (
|
|
122
|
+
r.acknowledge_id in ack_set
|
|
123
|
+
or is_acknowledged(repo_root, r.acknowledge_id)
|
|
124
|
+
):
|
|
125
|
+
r.passed = True
|
|
126
|
+
|
|
127
|
+
# Only GUARDs block commits. NUDGEs and NOTEs pass through.
|
|
128
|
+
passed = not any(
|
|
129
|
+
r.severity.blocks_commit and not r.passed
|
|
130
|
+
for r in results
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return CheckReport(
|
|
134
|
+
passed=passed,
|
|
135
|
+
results=[r for r in results if not r.passed],
|
|
136
|
+
files_checked=len(modified_files),
|
|
137
|
+
checks_run=9,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def check_staged(repo_root: str, session_id: Optional[str] = None) -> CheckReport:
|
|
142
|
+
"""Check currently staged changes."""
|
|
143
|
+
diff_text = _get_staged_diff(repo_root)
|
|
144
|
+
if not diff_text:
|
|
145
|
+
return CheckReport(passed=True, files_checked=0, checks_run=0)
|
|
146
|
+
return check_diff(diff_text, repo_root, session_id=session_id)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def format_terminal(report: CheckReport) -> str:
|
|
150
|
+
"""Format a check report for terminal output."""
|
|
151
|
+
if report.passed and not report.nudges and not report.notes:
|
|
152
|
+
return f"dotscope: {report.files_checked} files, {report.checks_run} checks -- clear"
|
|
153
|
+
|
|
154
|
+
lines = [f"dotscope: checking {report.files_checked} files"]
|
|
155
|
+
lines.append("")
|
|
156
|
+
|
|
157
|
+
for r in report.guards:
|
|
158
|
+
lines.append(f" GUARD {r.category.value}")
|
|
159
|
+
lines.append(f" {r.message}")
|
|
160
|
+
if r.suggestion:
|
|
161
|
+
lines.append(f" -> {r.suggestion}")
|
|
162
|
+
if r.can_acknowledge and r.acknowledge_id:
|
|
163
|
+
lines.append(f" -> Acknowledge: dotscope check --acknowledge {r.acknowledge_id}")
|
|
164
|
+
lines.append("")
|
|
165
|
+
|
|
166
|
+
for r in report.nudges:
|
|
167
|
+
lines.append(f" NUDGE {r.category.value}")
|
|
168
|
+
lines.append(f" {r.message}")
|
|
169
|
+
if r.suggestion:
|
|
170
|
+
lines.append(f" -> {r.suggestion}")
|
|
171
|
+
if r.proposed_fix and r.proposed_fix.predicted_sections:
|
|
172
|
+
sections = ", ".join(r.proposed_fix.predicted_sections)
|
|
173
|
+
lines.append(f" Likely needs changes: {sections}")
|
|
174
|
+
lines.append("")
|
|
175
|
+
|
|
176
|
+
for r in report.notes:
|
|
177
|
+
lines.append(f" NOTE {r.category.value}")
|
|
178
|
+
lines.append(f" {r.message}")
|
|
179
|
+
lines.append("")
|
|
180
|
+
|
|
181
|
+
guard_count = len(report.guards)
|
|
182
|
+
nudge_count = len(report.nudges)
|
|
183
|
+
note_count = len(report.notes)
|
|
184
|
+
if guard_count:
|
|
185
|
+
lines.append(f"dotscope: {guard_count} guard(s), {nudge_count} nudge(s), {note_count} note(s) -- address guards to proceed")
|
|
186
|
+
elif nudge_count:
|
|
187
|
+
lines.append(f"dotscope: {nudge_count} nudge(s), {note_count} note(s) -- clear (nudges are guidance, not gates)")
|
|
188
|
+
else:
|
|
189
|
+
lines.append(f"dotscope: {note_count} note(s) -- clear")
|
|
190
|
+
|
|
191
|
+
return "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Data loading helpers
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def _parse_diff(diff_text: str) -> tuple:
|
|
199
|
+
"""Parse unified diff into modified files and added lines per file."""
|
|
200
|
+
modified_files: List[str] = []
|
|
201
|
+
added_lines: Dict[str, List[str]] = {}
|
|
202
|
+
current_file = ""
|
|
203
|
+
|
|
204
|
+
for line in diff_text.splitlines():
|
|
205
|
+
if line.startswith("diff --git"):
|
|
206
|
+
parts = line.split(" b/", 1)
|
|
207
|
+
if len(parts) > 1:
|
|
208
|
+
current_file = parts[1]
|
|
209
|
+
if current_file not in modified_files:
|
|
210
|
+
modified_files.append(current_file)
|
|
211
|
+
added_lines.setdefault(current_file, [])
|
|
212
|
+
elif line.startswith("+") and not line.startswith("+++") and current_file:
|
|
213
|
+
added_lines.setdefault(current_file, []).append(line[1:])
|
|
214
|
+
|
|
215
|
+
return modified_files, added_lines
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _get_staged_diff(repo_root: str) -> str:
|
|
219
|
+
"""Get git diff of staged changes."""
|
|
220
|
+
try:
|
|
221
|
+
result = subprocess.run(
|
|
222
|
+
["git", "diff", "--cached"],
|
|
223
|
+
cwd=repo_root, capture_output=True, text=True, timeout=10,
|
|
224
|
+
)
|
|
225
|
+
return result.stdout if result.returncode == 0 else ""
|
|
226
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
227
|
+
return ""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _load_invariants(repo_root: str) -> dict:
|
|
231
|
+
"""Load invariants.json from .dotscope/, pruning stale references."""
|
|
232
|
+
path = os.path.join(repo_root, ".dotscope", "invariants.json")
|
|
233
|
+
if not os.path.exists(path):
|
|
234
|
+
return {}
|
|
235
|
+
try:
|
|
236
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
237
|
+
data = json.load(f)
|
|
238
|
+
except (json.JSONDecodeError, IOError):
|
|
239
|
+
return {}
|
|
240
|
+
|
|
241
|
+
# Prune contracts referencing files that no longer exist
|
|
242
|
+
contracts = data.get("contracts", [])
|
|
243
|
+
if contracts:
|
|
244
|
+
valid = []
|
|
245
|
+
for c in contracts:
|
|
246
|
+
trigger = c.get("trigger_file", "")
|
|
247
|
+
coupled = c.get("coupled_file", "")
|
|
248
|
+
if (os.path.isfile(os.path.join(repo_root, trigger))
|
|
249
|
+
and os.path.isfile(os.path.join(repo_root, coupled))):
|
|
250
|
+
valid.append(c)
|
|
251
|
+
data["contracts"] = valid
|
|
252
|
+
|
|
253
|
+
return data
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _load_scopes_with_antipatterns(repo_root: str) -> Dict[str, dict]:
|
|
257
|
+
"""Load scope files with anti_patterns field."""
|
|
258
|
+
from ...discovery import find_all_scopes
|
|
259
|
+
from ...parser import parse_scope_file, _parse_yaml
|
|
260
|
+
|
|
261
|
+
scopes = {}
|
|
262
|
+
for sf in find_all_scopes(repo_root):
|
|
263
|
+
try:
|
|
264
|
+
rel_dir = os.path.relpath(os.path.dirname(sf), repo_root)
|
|
265
|
+
if rel_dir == ".":
|
|
266
|
+
rel_dir = ""
|
|
267
|
+
|
|
268
|
+
# Parse the raw YAML to get anti_patterns (not in ScopeConfig model)
|
|
269
|
+
with open(sf, "r", encoding="utf-8") as f:
|
|
270
|
+
raw = _parse_yaml(f.read())
|
|
271
|
+
|
|
272
|
+
scopes[rel_dir] = {
|
|
273
|
+
"anti_patterns": raw.get("anti_patterns", []),
|
|
274
|
+
}
|
|
275
|
+
except (ValueError, IOError):
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
return scopes
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _load_graph_hubs(repo_root: str) -> Dict[str, object]:
|
|
282
|
+
"""Load cached graph hubs."""
|
|
283
|
+
try:
|
|
284
|
+
from ...cache import load_cached_graph_hubs
|
|
285
|
+
return load_cached_graph_hubs(repo_root)
|
|
286
|
+
except Exception:
|
|
287
|
+
return {}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _resolve_session(
|
|
291
|
+
repo_root: str,
|
|
292
|
+
session_id: Optional[str] = None,
|
|
293
|
+
) -> Optional[dict]:
|
|
294
|
+
"""Resolve which session to check boundaries against.
|
|
295
|
+
|
|
296
|
+
1. Explicit session_id → load that session
|
|
297
|
+
2. No session_id → find most recent session within 10 minutes
|
|
298
|
+
3. Fallback → None (skip boundary check)
|
|
299
|
+
"""
|
|
300
|
+
sessions_dir = os.path.join(repo_root, ".dotscope", "sessions")
|
|
301
|
+
|
|
302
|
+
if session_id:
|
|
303
|
+
path = os.path.join(sessions_dir, f"{session_id}.json")
|
|
304
|
+
if os.path.exists(path):
|
|
305
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
306
|
+
return json.load(f)
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
if os.path.isdir(sessions_dir):
|
|
310
|
+
sessions = sorted(
|
|
311
|
+
Path(sessions_dir).glob("*.json"),
|
|
312
|
+
key=lambda p: p.stat().st_mtime,
|
|
313
|
+
reverse=True,
|
|
314
|
+
)
|
|
315
|
+
for path in sessions[:5]:
|
|
316
|
+
try:
|
|
317
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
318
|
+
session = json.load(f)
|
|
319
|
+
ts = session.get("timestamp", 0)
|
|
320
|
+
if time.time() - ts < 600: # 10 minutes
|
|
321
|
+
return session
|
|
322
|
+
except (json.JSONDecodeError, IOError):
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _load_intents(repo_root: str) -> list:
|
|
329
|
+
"""Load architectural intents."""
|
|
330
|
+
try:
|
|
331
|
+
from ...intent import load_intents
|
|
332
|
+
return load_intents(repo_root)
|
|
333
|
+
except Exception:
|
|
334
|
+
return []
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _load_conventions_and_ast(
|
|
338
|
+
repo_root: str,
|
|
339
|
+
modified_files: List[str],
|
|
340
|
+
) -> tuple:
|
|
341
|
+
"""Load conventions and parse AST for modified files."""
|
|
342
|
+
try:
|
|
343
|
+
from ...intent import load_conventions
|
|
344
|
+
conventions = load_conventions(repo_root)
|
|
345
|
+
except Exception:
|
|
346
|
+
conventions = []
|
|
347
|
+
|
|
348
|
+
ast_data = {}
|
|
349
|
+
if conventions:
|
|
350
|
+
try:
|
|
351
|
+
from ..ast_analyzer import analyze_file
|
|
352
|
+
for filepath in modified_files:
|
|
353
|
+
full_path = os.path.join(repo_root, filepath)
|
|
354
|
+
if os.path.isfile(full_path):
|
|
355
|
+
lang = _detect_language(filepath)
|
|
356
|
+
if lang:
|
|
357
|
+
analysis = analyze_file(full_path, lang)
|
|
358
|
+
if analysis:
|
|
359
|
+
ast_data[filepath] = analysis
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
return conventions, ast_data
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _detect_language(filepath: str) -> Optional[str]:
|
|
367
|
+
"""Detect language from file extension."""
|
|
368
|
+
ext = os.path.splitext(filepath)[1].lower()
|
|
369
|
+
return {
|
|
370
|
+
".py": "python",
|
|
371
|
+
".js": "javascript",
|
|
372
|
+
".ts": "typescript",
|
|
373
|
+
".go": "go",
|
|
374
|
+
}.get(ext)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _load_voice_config(repo_root: str) -> Optional[dict]:
|
|
378
|
+
"""Load voice config from intent.yaml."""
|
|
379
|
+
try:
|
|
380
|
+
from ...intent import load_voice_config
|
|
381
|
+
return load_voice_config(repo_root)
|
|
382
|
+
except Exception:
|
|
383
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Individual check implementations."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Anti-pattern check: diff introduces patterns a scope prohibits."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from ..models import CheckCategory, CheckResult, ProposedFix, Severity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_antipatterns(
|
|
10
|
+
added_lines: Dict[str, List[str]],
|
|
11
|
+
scopes: Dict[str, dict],
|
|
12
|
+
repo_root: str,
|
|
13
|
+
) -> List[CheckResult]:
|
|
14
|
+
"""Check added lines against anti_patterns defined in scope files.
|
|
15
|
+
|
|
16
|
+
Each scope may have:
|
|
17
|
+
anti_patterns:
|
|
18
|
+
- pattern: "\\.delete\\(\\)"
|
|
19
|
+
replacement: ".deactivate()"
|
|
20
|
+
scope_files: ["models/user.py"]
|
|
21
|
+
message: "Use .deactivate() instead of .delete() on User"
|
|
22
|
+
"""
|
|
23
|
+
results = []
|
|
24
|
+
|
|
25
|
+
for scope_dir, scope_data in scopes.items():
|
|
26
|
+
patterns = scope_data.get("anti_patterns", [])
|
|
27
|
+
if not patterns:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
for ap in patterns:
|
|
31
|
+
pattern_str = ap.get("pattern", "")
|
|
32
|
+
if not pattern_str:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
regex = re.compile(pattern_str)
|
|
37
|
+
except re.error:
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
scope_files = ap.get("scope_files", [])
|
|
41
|
+
message = ap.get("message", f"Matches prohibited pattern: {pattern_str}")
|
|
42
|
+
replacement = ap.get("replacement")
|
|
43
|
+
|
|
44
|
+
for filepath, lines in added_lines.items():
|
|
45
|
+
# If scope_files is specified, only check those files
|
|
46
|
+
if scope_files and not any(filepath.endswith(sf) or filepath == sf for sf in scope_files):
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
# Check if file is in this scope's directory
|
|
50
|
+
if not scope_files and not filepath.startswith(scope_dir):
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
from ..line_filter import strip_comments_and_strings
|
|
54
|
+
|
|
55
|
+
for line_text in lines:
|
|
56
|
+
code_only = strip_comments_and_strings(line_text)
|
|
57
|
+
if not code_only.strip():
|
|
58
|
+
continue
|
|
59
|
+
if regex.search(code_only):
|
|
60
|
+
fix = None
|
|
61
|
+
if replacement:
|
|
62
|
+
fixed = regex.sub(replacement, line_text)
|
|
63
|
+
fix = ProposedFix(
|
|
64
|
+
file=filepath,
|
|
65
|
+
reason=message,
|
|
66
|
+
proposed_diff=f"-{line_text.strip()}\n+{fixed.strip()}",
|
|
67
|
+
confidence=1.0,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
results.append(CheckResult(
|
|
71
|
+
passed=False,
|
|
72
|
+
category=CheckCategory.ANTIPATTERN,
|
|
73
|
+
severity=Severity.NUDGE,
|
|
74
|
+
message=message,
|
|
75
|
+
detail=f"Pattern: {pattern_str} in {filepath}",
|
|
76
|
+
file=filepath,
|
|
77
|
+
suggestion=f"Use {replacement}" if replacement else "See scope context",
|
|
78
|
+
proposed_fix=fix,
|
|
79
|
+
can_acknowledge=True,
|
|
80
|
+
acknowledge_id=f"antipattern_{pattern_str[:20].replace('.', '_')}",
|
|
81
|
+
))
|
|
82
|
+
break # One match per file per pattern is enough
|
|
83
|
+
|
|
84
|
+
return results
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Boundary violation check: agent modified files outside resolved scopes."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from ..models import CheckCategory, CheckResult, Severity
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def check_boundaries(
|
|
9
|
+
modified_files: List[str],
|
|
10
|
+
session: Optional[dict],
|
|
11
|
+
scopes: Dict[str, object],
|
|
12
|
+
) -> List[CheckResult]:
|
|
13
|
+
"""Check if modified files fall outside the session's resolved scopes."""
|
|
14
|
+
if session is None:
|
|
15
|
+
return [] # No session data — skip boundary check
|
|
16
|
+
|
|
17
|
+
resolved_files = set(session.get("predicted_files", []))
|
|
18
|
+
if not resolved_files:
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
results = []
|
|
22
|
+
for f in modified_files:
|
|
23
|
+
if f not in resolved_files:
|
|
24
|
+
# Check if it's in ANY scope
|
|
25
|
+
in_scope = any(
|
|
26
|
+
f.startswith(scope_dir + "/") or f.startswith(scope_dir)
|
|
27
|
+
for scope_dir in scopes
|
|
28
|
+
)
|
|
29
|
+
suggestion = (
|
|
30
|
+
"Resolve the relevant scope first"
|
|
31
|
+
if in_scope
|
|
32
|
+
else "This file isn't covered by any scope"
|
|
33
|
+
)
|
|
34
|
+
results.append(CheckResult(
|
|
35
|
+
passed=False,
|
|
36
|
+
category=CheckCategory.BOUNDARY,
|
|
37
|
+
severity=Severity.NUDGE,
|
|
38
|
+
message=f"Modified {f} outside resolved scope",
|
|
39
|
+
detail=f"Session resolved: {len(resolved_files)} files, this file was not included",
|
|
40
|
+
file=f,
|
|
41
|
+
suggestion=suggestion,
|
|
42
|
+
can_acknowledge=True,
|
|
43
|
+
acknowledge_id=f"boundary_{f.replace('/', '_').replace('.', '_')}",
|
|
44
|
+
))
|
|
45
|
+
|
|
46
|
+
return results
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Implicit contract check: coupled files modified without their pair."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from ..models import CheckCategory, CheckResult, ProposedFix, Severity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def check_contracts(
|
|
10
|
+
modified_files: List[str],
|
|
11
|
+
invariants: dict,
|
|
12
|
+
diff_text: str,
|
|
13
|
+
) -> List[CheckResult]:
|
|
14
|
+
"""Check implicit contracts — if A changed, did B change too?"""
|
|
15
|
+
contracts = invariants.get("contracts", [])
|
|
16
|
+
if not contracts:
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
modified_set = set(modified_files)
|
|
20
|
+
results = []
|
|
21
|
+
|
|
22
|
+
for contract in contracts:
|
|
23
|
+
trigger = contract.get("trigger_file", "")
|
|
24
|
+
coupled = contract.get("coupled_file", "")
|
|
25
|
+
confidence = contract.get("confidence", 0.0)
|
|
26
|
+
|
|
27
|
+
if confidence < 0.65:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
# Both modified → satisfied
|
|
31
|
+
if trigger in modified_set and coupled in modified_set:
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
# Only one side modified → violation
|
|
35
|
+
if trigger in modified_set and coupled not in modified_set:
|
|
36
|
+
fix = _build_fix_proposal(trigger, coupled, diff_text, invariants)
|
|
37
|
+
ack_id = _ack_id(trigger, coupled)
|
|
38
|
+
results.append(CheckResult(
|
|
39
|
+
passed=False,
|
|
40
|
+
category=CheckCategory.CONTRACT,
|
|
41
|
+
severity=Severity.NUDGE,
|
|
42
|
+
message=(
|
|
43
|
+
f"{trigger} modified without {coupled} "
|
|
44
|
+
f"({confidence:.0%} co-change rate)"
|
|
45
|
+
),
|
|
46
|
+
detail=contract.get("description", ""),
|
|
47
|
+
file=trigger,
|
|
48
|
+
suggestion=f"Review {coupled} for necessary changes",
|
|
49
|
+
proposed_fix=fix,
|
|
50
|
+
can_acknowledge=True,
|
|
51
|
+
acknowledge_id=ack_id,
|
|
52
|
+
))
|
|
53
|
+
|
|
54
|
+
elif coupled in modified_set and trigger not in modified_set:
|
|
55
|
+
fix = _build_fix_proposal(coupled, trigger, diff_text, invariants)
|
|
56
|
+
ack_id = _ack_id(coupled, trigger)
|
|
57
|
+
results.append(CheckResult(
|
|
58
|
+
passed=False,
|
|
59
|
+
category=CheckCategory.CONTRACT,
|
|
60
|
+
severity=Severity.NUDGE,
|
|
61
|
+
message=(
|
|
62
|
+
f"{coupled} modified without {trigger} "
|
|
63
|
+
f"({confidence:.0%} co-change rate)"
|
|
64
|
+
),
|
|
65
|
+
detail=contract.get("description", ""),
|
|
66
|
+
file=coupled,
|
|
67
|
+
suggestion=f"Review {trigger} for necessary changes",
|
|
68
|
+
proposed_fix=fix,
|
|
69
|
+
can_acknowledge=True,
|
|
70
|
+
acknowledge_id=ack_id,
|
|
71
|
+
))
|
|
72
|
+
|
|
73
|
+
return results
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _build_fix_proposal(
|
|
77
|
+
modified_file: str,
|
|
78
|
+
coupled_file: str,
|
|
79
|
+
diff_text: str,
|
|
80
|
+
invariants: dict,
|
|
81
|
+
) -> ProposedFix:
|
|
82
|
+
"""Build a fix proposal, using function-level co-change data if available."""
|
|
83
|
+
function_co = invariants.get("function_co_changes", {})
|
|
84
|
+
|
|
85
|
+
# Find modified functions in the diff
|
|
86
|
+
modified_fns = _extract_modified_functions(modified_file, diff_text)
|
|
87
|
+
|
|
88
|
+
predicted_sections = []
|
|
89
|
+
total_confidence = 0.0
|
|
90
|
+
|
|
91
|
+
for fn in modified_fns:
|
|
92
|
+
key = f"{modified_file}:{fn}"
|
|
93
|
+
pairs = function_co.get(key, [])
|
|
94
|
+
for pair in pairs:
|
|
95
|
+
if pair.get("file") == coupled_file:
|
|
96
|
+
fn = pair.get("function")
|
|
97
|
+
if fn:
|
|
98
|
+
predicted_sections.append(fn)
|
|
99
|
+
total_confidence += pair.get("confidence", 0.5)
|
|
100
|
+
|
|
101
|
+
if predicted_sections:
|
|
102
|
+
avg = total_confidence / len(predicted_sections)
|
|
103
|
+
return ProposedFix(
|
|
104
|
+
file=coupled_file,
|
|
105
|
+
reason=f"When {modified_file} changes, these sections typically need updates",
|
|
106
|
+
predicted_sections=predicted_sections,
|
|
107
|
+
confidence=round(avg, 2),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return ProposedFix(
|
|
111
|
+
file=coupled_file,
|
|
112
|
+
reason=f"Historically changes alongside {modified_file}",
|
|
113
|
+
confidence=0.5,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _extract_modified_functions(filepath: str, diff_text: str) -> List[str]:
|
|
118
|
+
"""Extract function names modified in the diff for a specific file."""
|
|
119
|
+
functions = []
|
|
120
|
+
in_file = False
|
|
121
|
+
|
|
122
|
+
for line in diff_text.splitlines():
|
|
123
|
+
if line.startswith("diff --git"):
|
|
124
|
+
in_file = filepath in line
|
|
125
|
+
elif in_file and line.startswith("@@"):
|
|
126
|
+
# Hunk header may contain function name
|
|
127
|
+
if "def " in line:
|
|
128
|
+
parts = line.split("def ", 1)
|
|
129
|
+
if len(parts) > 1:
|
|
130
|
+
fn_name = parts[1].split("(")[0].strip()
|
|
131
|
+
if fn_name:
|
|
132
|
+
functions.append(fn_name)
|
|
133
|
+
elif in_file and line.startswith("+") and not line.startswith("+++"):
|
|
134
|
+
if "def " in line:
|
|
135
|
+
parts = line.split("def ", 1)
|
|
136
|
+
if len(parts) > 1:
|
|
137
|
+
fn_name = parts[1].split("(")[0].strip()
|
|
138
|
+
if fn_name:
|
|
139
|
+
functions.append(fn_name)
|
|
140
|
+
|
|
141
|
+
return list(dict.fromkeys(functions)) # Deduplicate, preserve order
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _ack_id(file_a: str, file_b: str) -> str:
|
|
145
|
+
slug = hashlib.md5(f"{file_a}:{file_b}".encode()).hexdigest()[:6]
|
|
146
|
+
a_short = file_a.replace("/", "_").replace(".", "_")[:20]
|
|
147
|
+
b_short = file_b.replace("/", "_").replace(".", "_")[:20]
|
|
148
|
+
return f"contract_{a_short}_{b_short}_{slug}"
|