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,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