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
@@ -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}"