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,472 @@
|
|
|
1
|
+
"""Build and filter constraints for prophylactic injection into resolve_scope."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from .models import Constraint, ConventionRule, IntentDirective
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_constraints(
|
|
10
|
+
scope_dir: str,
|
|
11
|
+
repo_root: str,
|
|
12
|
+
invariants: dict,
|
|
13
|
+
scopes: Dict[str, dict],
|
|
14
|
+
intents: List[IntentDirective],
|
|
15
|
+
graph_hubs: Optional[Dict[str, object]] = None,
|
|
16
|
+
task: Optional[str] = None,
|
|
17
|
+
conventions: Optional[List[ConventionRule]] = None,
|
|
18
|
+
) -> List[Constraint]:
|
|
19
|
+
"""Build filtered constraints relevant to a resolved scope.
|
|
20
|
+
|
|
21
|
+
Filters to constraints touching the resolved scope's includes.
|
|
22
|
+
If task is provided, ranks by keyword relevance and caps at 5 per category.
|
|
23
|
+
"""
|
|
24
|
+
constraints = []
|
|
25
|
+
|
|
26
|
+
# 1. Implicit contracts where at least one side is in scope
|
|
27
|
+
for contract in invariants.get("contracts", []):
|
|
28
|
+
trigger = contract.get("trigger_file", "")
|
|
29
|
+
coupled = contract.get("coupled_file", "")
|
|
30
|
+
confidence = contract.get("confidence", 0.0)
|
|
31
|
+
|
|
32
|
+
if confidence < 0.65:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
if _in_scope(trigger, scope_dir) or _in_scope(coupled, scope_dir):
|
|
36
|
+
constraints.append(Constraint(
|
|
37
|
+
category="contract",
|
|
38
|
+
message=(
|
|
39
|
+
f"If you modify {trigger}, review {coupled} for necessary changes"
|
|
40
|
+
),
|
|
41
|
+
file=trigger,
|
|
42
|
+
confidence=confidence,
|
|
43
|
+
metadata={"coupled_with": coupled, "co_change_rate": confidence},
|
|
44
|
+
))
|
|
45
|
+
|
|
46
|
+
# 2. Anti-patterns targeting files in scope
|
|
47
|
+
scope_data = scopes.get(scope_dir, {})
|
|
48
|
+
for ap in scope_data.get("anti_patterns", []):
|
|
49
|
+
constraints.append(Constraint(
|
|
50
|
+
category="anti_pattern",
|
|
51
|
+
message=ap.get("message", ""),
|
|
52
|
+
confidence=1.0,
|
|
53
|
+
metadata={
|
|
54
|
+
"pattern": ap.get("pattern", ""),
|
|
55
|
+
"replacement": ap.get("replacement"),
|
|
56
|
+
"scope_files": ap.get("scope_files", []),
|
|
57
|
+
},
|
|
58
|
+
))
|
|
59
|
+
|
|
60
|
+
# 3. Dependency boundaries from graph
|
|
61
|
+
if graph_hubs:
|
|
62
|
+
for hub_file, hub_data in graph_hubs.items():
|
|
63
|
+
if not isinstance(hub_data, dict):
|
|
64
|
+
continue
|
|
65
|
+
if not _in_scope(hub_file, scope_dir):
|
|
66
|
+
continue
|
|
67
|
+
imported_by = hub_data.get("imported_by", [])
|
|
68
|
+
if imported_by:
|
|
69
|
+
# Determine the direction
|
|
70
|
+
hub_module = hub_file.split("/")[0] if "/" in hub_file else ""
|
|
71
|
+
importer_modules = set()
|
|
72
|
+
for imp in imported_by:
|
|
73
|
+
mod = imp.split("/")[0] if "/" in imp else ""
|
|
74
|
+
if mod and mod != hub_module:
|
|
75
|
+
importer_modules.add(mod)
|
|
76
|
+
if importer_modules:
|
|
77
|
+
constraints.append(Constraint(
|
|
78
|
+
category="dependency_boundary",
|
|
79
|
+
message=(
|
|
80
|
+
f"{hub_module}/ is imported by {', '.join(sorted(importer_modules))}/, "
|
|
81
|
+
f"not the other way around"
|
|
82
|
+
),
|
|
83
|
+
file=hub_file,
|
|
84
|
+
confidence=0.9,
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
# 4. Stability notes
|
|
88
|
+
for filepath, info in invariants.get("file_stabilities", {}).items():
|
|
89
|
+
if not _in_scope(filepath, scope_dir):
|
|
90
|
+
continue
|
|
91
|
+
if info.get("classification") == "stable":
|
|
92
|
+
constraints.append(Constraint(
|
|
93
|
+
category="stability",
|
|
94
|
+
message=(
|
|
95
|
+
f"{filepath} is stable ({info.get('commit_count', 0)} commits). "
|
|
96
|
+
f"Large changes deserve extra review."
|
|
97
|
+
),
|
|
98
|
+
file=filepath,
|
|
99
|
+
confidence=0.8,
|
|
100
|
+
))
|
|
101
|
+
|
|
102
|
+
# 5. Architectural intents mentioning this module
|
|
103
|
+
for intent in intents:
|
|
104
|
+
scope_mod = scope_dir.rstrip("/") + "/"
|
|
105
|
+
if scope_mod in intent.modules or any(
|
|
106
|
+
f.startswith(scope_dir) for f in intent.files
|
|
107
|
+
):
|
|
108
|
+
constraints.append(Constraint(
|
|
109
|
+
category="intent",
|
|
110
|
+
message=_format_intent(intent),
|
|
111
|
+
confidence=1.0,
|
|
112
|
+
metadata={
|
|
113
|
+
"directive": intent.directive,
|
|
114
|
+
"set_by": intent.set_by,
|
|
115
|
+
"set_at": intent.set_at,
|
|
116
|
+
},
|
|
117
|
+
))
|
|
118
|
+
|
|
119
|
+
# 6. Convention blueprints matching this scope
|
|
120
|
+
for conv in (conventions or []):
|
|
121
|
+
if conv.compliance < 0.50:
|
|
122
|
+
continue # Skip retired conventions
|
|
123
|
+
rules_summary = []
|
|
124
|
+
if conv.rules.get("prohibited_imports"):
|
|
125
|
+
rules_summary.append(
|
|
126
|
+
f"Do not import: {', '.join(conv.rules['prohibited_imports'])}"
|
|
127
|
+
)
|
|
128
|
+
if conv.rules.get("required_methods"):
|
|
129
|
+
rules_summary.append(
|
|
130
|
+
f"Must implement: {', '.join(conv.rules['required_methods'])}"
|
|
131
|
+
)
|
|
132
|
+
if rules_summary:
|
|
133
|
+
constraints.append(Constraint(
|
|
134
|
+
category="convention",
|
|
135
|
+
message=(
|
|
136
|
+
f"Convention '{conv.name}': {'; '.join(rules_summary)}"
|
|
137
|
+
),
|
|
138
|
+
confidence=conv.compliance,
|
|
139
|
+
metadata={
|
|
140
|
+
"convention": conv.name,
|
|
141
|
+
"description": conv.description,
|
|
142
|
+
"compliance": conv.compliance,
|
|
143
|
+
},
|
|
144
|
+
))
|
|
145
|
+
|
|
146
|
+
# Filter by task relevance if provided
|
|
147
|
+
if task:
|
|
148
|
+
constraints = _rank_by_task(constraints, task)
|
|
149
|
+
|
|
150
|
+
# Cap at 5 per category
|
|
151
|
+
return _cap_per_category(constraints, max_per=5)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_routing_guidance(
|
|
155
|
+
scope_dir: str,
|
|
156
|
+
conventions: Optional[List[ConventionRule]] = None,
|
|
157
|
+
voice_config: Optional[dict] = None,
|
|
158
|
+
repo_root: Optional[str] = None,
|
|
159
|
+
) -> List[Constraint]:
|
|
160
|
+
"""Build positive-frame routing guidance: what patterns apply here.
|
|
161
|
+
|
|
162
|
+
Constraints tell agents what NOT to do. Routing tells agents what TO do.
|
|
163
|
+
This is the bowling bumper: the agent reads it and writes code that
|
|
164
|
+
already follows the rules.
|
|
165
|
+
"""
|
|
166
|
+
guidance: List[Constraint] = []
|
|
167
|
+
|
|
168
|
+
for conv in (conventions or []):
|
|
169
|
+
if conv.compliance < 0.50:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
rules = conv.rules or {}
|
|
173
|
+
rules_summary = _convention_rules_summary(rules)
|
|
174
|
+
|
|
175
|
+
# Existing-file routing: what convention files in this scope follow
|
|
176
|
+
parts = [f"Files here follow the '{conv.name}' convention"]
|
|
177
|
+
if conv.description:
|
|
178
|
+
parts.append(conv.description)
|
|
179
|
+
if rules_summary:
|
|
180
|
+
parts.extend(rules_summary)
|
|
181
|
+
guidance.append(Constraint(
|
|
182
|
+
category="routing",
|
|
183
|
+
message=". ".join(parts),
|
|
184
|
+
confidence=conv.compliance,
|
|
185
|
+
metadata={"convention": conv.name, "type": "convention_blueprint"},
|
|
186
|
+
))
|
|
187
|
+
|
|
188
|
+
# Gap 1: Path-first routing for new files
|
|
189
|
+
# If convention has file_path match criteria, inject guidance for
|
|
190
|
+
# files that don't exist yet
|
|
191
|
+
for criteria_list in (
|
|
192
|
+
conv.match_criteria.get("any_of", []),
|
|
193
|
+
conv.match_criteria.get("all_of", []),
|
|
194
|
+
):
|
|
195
|
+
for criterion in criteria_list:
|
|
196
|
+
if isinstance(criterion, dict) and "file_path" in criterion:
|
|
197
|
+
pattern = criterion["file_path"]
|
|
198
|
+
parts_new = [
|
|
199
|
+
f"New files matching pattern {pattern} "
|
|
200
|
+
f"should follow '{conv.name}' convention"
|
|
201
|
+
]
|
|
202
|
+
if rules_summary:
|
|
203
|
+
parts_new.extend(rules_summary)
|
|
204
|
+
guidance.append(Constraint(
|
|
205
|
+
category="routing",
|
|
206
|
+
message=". ".join(parts_new),
|
|
207
|
+
confidence=conv.compliance,
|
|
208
|
+
metadata={
|
|
209
|
+
"convention": conv.name,
|
|
210
|
+
"type": "path_pattern",
|
|
211
|
+
"pattern": pattern,
|
|
212
|
+
},
|
|
213
|
+
))
|
|
214
|
+
|
|
215
|
+
# Voice guidance
|
|
216
|
+
if voice_config and voice_config.get("mode"):
|
|
217
|
+
voice_parts = []
|
|
218
|
+
for key in ("typing", "docstrings", "error_handling", "structure", "density"):
|
|
219
|
+
val = voice_config.get(key)
|
|
220
|
+
if val and isinstance(val, str):
|
|
221
|
+
voice_parts.append(val.strip().split("\n")[0])
|
|
222
|
+
if voice_parts:
|
|
223
|
+
guidance.append(Constraint(
|
|
224
|
+
category="routing",
|
|
225
|
+
message="Code style: " + ". ".join(voice_parts),
|
|
226
|
+
confidence=0.9,
|
|
227
|
+
metadata={"type": "voice"},
|
|
228
|
+
))
|
|
229
|
+
|
|
230
|
+
# Gap 6: Learned routing from observations
|
|
231
|
+
if repo_root:
|
|
232
|
+
learned = _learned_routing(scope_dir, repo_root)
|
|
233
|
+
guidance.extend(learned)
|
|
234
|
+
|
|
235
|
+
# Deduplicate: if two conventions match the same path pattern,
|
|
236
|
+
# keep the one with higher compliance
|
|
237
|
+
return _deduplicate_routing(guidance)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def build_adjacent_routing(
|
|
241
|
+
scope_dir: str,
|
|
242
|
+
graph_hubs: Optional[Dict[str, object]] = None,
|
|
243
|
+
all_scopes: Optional[Dict[str, dict]] = None,
|
|
244
|
+
conventions: Optional[List[ConventionRule]] = None,
|
|
245
|
+
) -> List[Constraint]:
|
|
246
|
+
"""Gap 2: Routing for scopes the agent is likely to touch next.
|
|
247
|
+
|
|
248
|
+
When resolving scope X, check which other scopes X's files import from.
|
|
249
|
+
Include a compact routing summary for those adjacent scopes.
|
|
250
|
+
"""
|
|
251
|
+
if not graph_hubs or not all_scopes:
|
|
252
|
+
return []
|
|
253
|
+
|
|
254
|
+
adjacent_modules: set = set()
|
|
255
|
+
scope_mod = scope_dir.rstrip("/")
|
|
256
|
+
|
|
257
|
+
for hub_file, hub_data in graph_hubs.items():
|
|
258
|
+
if not isinstance(hub_data, dict):
|
|
259
|
+
continue
|
|
260
|
+
if not _in_scope(hub_file, scope_dir):
|
|
261
|
+
continue
|
|
262
|
+
for imp in hub_data.get("imported_by", []):
|
|
263
|
+
mod = imp.split("/")[0] if "/" in imp else ""
|
|
264
|
+
if mod and mod != scope_mod:
|
|
265
|
+
adjacent_modules.add(mod)
|
|
266
|
+
for dep in hub_data.get("imports", []):
|
|
267
|
+
mod = dep.split("/")[0] if "/" in dep else ""
|
|
268
|
+
if mod and mod != scope_mod:
|
|
269
|
+
adjacent_modules.add(mod)
|
|
270
|
+
|
|
271
|
+
guidance: List[Constraint] = []
|
|
272
|
+
for mod in sorted(adjacent_modules):
|
|
273
|
+
scope_data = all_scopes.get(mod, {})
|
|
274
|
+
desc = scope_data.get("description", "")
|
|
275
|
+
parts = [f"Adjacent scope: {mod}/"]
|
|
276
|
+
if desc:
|
|
277
|
+
parts.append(desc)
|
|
278
|
+
|
|
279
|
+
# Find conventions that apply to this adjacent scope
|
|
280
|
+
for conv in (conventions or []):
|
|
281
|
+
if conv.compliance < 0.50:
|
|
282
|
+
continue
|
|
283
|
+
for criteria_list in (
|
|
284
|
+
conv.match_criteria.get("any_of", []),
|
|
285
|
+
conv.match_criteria.get("all_of", []),
|
|
286
|
+
):
|
|
287
|
+
for criterion in criteria_list:
|
|
288
|
+
if isinstance(criterion, dict):
|
|
289
|
+
fp = criterion.get("file_path", "")
|
|
290
|
+
if fp and mod in fp:
|
|
291
|
+
rules_summary = _convention_rules_summary(conv.rules or {})
|
|
292
|
+
parts.append(f"Convention '{conv.name}'")
|
|
293
|
+
parts.extend(rules_summary)
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
if len(parts) > 1: # Only include if we have something beyond the name
|
|
297
|
+
guidance.append(Constraint(
|
|
298
|
+
category="routing_adjacent",
|
|
299
|
+
message=". ".join(parts),
|
|
300
|
+
confidence=0.7,
|
|
301
|
+
metadata={"adjacent_scope": mod, "type": "adjacent"},
|
|
302
|
+
))
|
|
303
|
+
|
|
304
|
+
return guidance[:5] # Cap at 5 adjacent scopes
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def match_conventions_by_path(
|
|
308
|
+
filepath: str,
|
|
309
|
+
conventions: List[ConventionRule],
|
|
310
|
+
) -> List[dict]:
|
|
311
|
+
"""Gap 5: File creation advisor. Match conventions by path only (no AST needed).
|
|
312
|
+
|
|
313
|
+
Returns matching conventions with their rules for a file that may not exist yet.
|
|
314
|
+
"""
|
|
315
|
+
import re
|
|
316
|
+
matches = []
|
|
317
|
+
for conv in conventions:
|
|
318
|
+
if conv.compliance < 0.50:
|
|
319
|
+
continue
|
|
320
|
+
for criteria_list in (
|
|
321
|
+
conv.match_criteria.get("any_of", []),
|
|
322
|
+
conv.match_criteria.get("all_of", []),
|
|
323
|
+
):
|
|
324
|
+
for criterion in criteria_list:
|
|
325
|
+
if not isinstance(criterion, dict):
|
|
326
|
+
continue
|
|
327
|
+
fp = criterion.get("file_path", "")
|
|
328
|
+
if fp:
|
|
329
|
+
try:
|
|
330
|
+
if re.search(fp, filepath):
|
|
331
|
+
matches.append({
|
|
332
|
+
"convention": conv.name,
|
|
333
|
+
"description": conv.description,
|
|
334
|
+
"rules": conv.rules,
|
|
335
|
+
"compliance": conv.compliance,
|
|
336
|
+
"matched_by": f"file_path: {fp}",
|
|
337
|
+
})
|
|
338
|
+
except re.error:
|
|
339
|
+
pass
|
|
340
|
+
# Also match class_ends_with against filename
|
|
341
|
+
suffix = criterion.get("class_ends_with", "")
|
|
342
|
+
if suffix and suffix.lower() in filepath.lower():
|
|
343
|
+
matches.append({
|
|
344
|
+
"convention": conv.name,
|
|
345
|
+
"description": conv.description,
|
|
346
|
+
"rules": conv.rules,
|
|
347
|
+
"compliance": conv.compliance,
|
|
348
|
+
"matched_by": f"class_ends_with: {suffix} (from filename)",
|
|
349
|
+
})
|
|
350
|
+
# Deduplicate by convention name
|
|
351
|
+
seen = set()
|
|
352
|
+
result = []
|
|
353
|
+
for m in matches:
|
|
354
|
+
if m["convention"] not in seen:
|
|
355
|
+
seen.add(m["convention"])
|
|
356
|
+
result.append(m)
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _deduplicate_routing(guidance: List[Constraint]) -> List[Constraint]:
|
|
361
|
+
"""Deduplicate routing by (convention, type), keeping highest compliance.
|
|
362
|
+
|
|
363
|
+
If two conventions with the same name AND same type produce guidance,
|
|
364
|
+
keep the one with higher compliance. Different types (blueprint vs
|
|
365
|
+
path_pattern) for the same convention are both kept.
|
|
366
|
+
"""
|
|
367
|
+
best: Dict[tuple, Constraint] = {}
|
|
368
|
+
non_convention: List[Constraint] = []
|
|
369
|
+
|
|
370
|
+
for g in guidance:
|
|
371
|
+
conv_name = g.metadata.get("convention")
|
|
372
|
+
if not conv_name:
|
|
373
|
+
non_convention.append(g)
|
|
374
|
+
continue
|
|
375
|
+
gtype = g.metadata.get("type", "")
|
|
376
|
+
key = (conv_name, gtype)
|
|
377
|
+
existing = best.get(key)
|
|
378
|
+
if existing is None or g.confidence > existing.confidence:
|
|
379
|
+
best[key] = g
|
|
380
|
+
|
|
381
|
+
return list(best.values()) + non_convention
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _convention_rules_summary(rules: dict) -> List[str]:
|
|
385
|
+
"""Build a compact rules summary list for a convention."""
|
|
386
|
+
parts = []
|
|
387
|
+
if rules.get("required_methods"):
|
|
388
|
+
parts.append(f"Implement: {', '.join(rules['required_methods'])}")
|
|
389
|
+
if rules.get("prohibited_imports"):
|
|
390
|
+
parts.append(f"Do not import: {', '.join(rules['prohibited_imports'])}")
|
|
391
|
+
return parts
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _learned_routing(scope_dir: str, repo_root: str) -> List[Constraint]:
|
|
395
|
+
"""Gap 6: Inject routing from observation data.
|
|
396
|
+
|
|
397
|
+
If agents repeatedly needed file X when resolving scope Y but X isn't
|
|
398
|
+
in Y's includes, mention it as routing guidance.
|
|
399
|
+
"""
|
|
400
|
+
import json
|
|
401
|
+
scores_path = os.path.join(repo_root, ".dotscope", "utility_scores.json")
|
|
402
|
+
if not os.path.exists(scores_path):
|
|
403
|
+
return []
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
with open(scores_path, "r", encoding="utf-8") as f:
|
|
407
|
+
scores = json.load(f)
|
|
408
|
+
except (json.JSONDecodeError, IOError):
|
|
409
|
+
return []
|
|
410
|
+
|
|
411
|
+
guidance = []
|
|
412
|
+
scope_scores = scores.get(scope_dir, scores.get(scope_dir.rstrip("/"), {}))
|
|
413
|
+
if not isinstance(scope_scores, dict):
|
|
414
|
+
return []
|
|
415
|
+
|
|
416
|
+
# Files with high utility that aren't in this scope
|
|
417
|
+
for filepath, score in sorted(scope_scores.items(), key=lambda x: x[1], reverse=True):
|
|
418
|
+
if isinstance(score, (int, float)) and score >= 3.0:
|
|
419
|
+
if not _in_scope(filepath, scope_dir):
|
|
420
|
+
guidance.append(Constraint(
|
|
421
|
+
category="routing",
|
|
422
|
+
message=(
|
|
423
|
+
f"Agents frequently need {filepath} when working in {scope_dir} "
|
|
424
|
+
f"(utility score: {score:.1f})"
|
|
425
|
+
),
|
|
426
|
+
confidence=min(score / 5.0, 0.95),
|
|
427
|
+
metadata={"type": "learned", "file": filepath, "score": score},
|
|
428
|
+
))
|
|
429
|
+
if len(guidance) >= 3:
|
|
430
|
+
break
|
|
431
|
+
|
|
432
|
+
return guidance
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _in_scope(filepath: str, scope_dir: str) -> bool:
|
|
436
|
+
"""Check if a file falls within a scope directory."""
|
|
437
|
+
return filepath.startswith(scope_dir) or filepath.startswith(scope_dir + "/")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _format_intent(intent: IntentDirective) -> str:
|
|
441
|
+
"""Format an intent as a one-line constraint message."""
|
|
442
|
+
targets = ", ".join(intent.modules + intent.files)
|
|
443
|
+
parts = [f"{intent.directive} {targets}"]
|
|
444
|
+
if intent.reason:
|
|
445
|
+
parts.append(intent.reason)
|
|
446
|
+
if intent.replacement:
|
|
447
|
+
parts.append(f"Use {intent.replacement}")
|
|
448
|
+
return ": ".join(parts)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _rank_by_task(constraints: List[Constraint], task: str) -> List[Constraint]:
|
|
452
|
+
"""Rank constraints by keyword overlap with task description."""
|
|
453
|
+
task_words = set(task.lower().split())
|
|
454
|
+
|
|
455
|
+
def relevance(c: Constraint) -> float:
|
|
456
|
+
words = set(c.message.lower().split())
|
|
457
|
+
overlap = task_words & words
|
|
458
|
+
return len(overlap) / max(len(task_words), 1)
|
|
459
|
+
|
|
460
|
+
return sorted(constraints, key=relevance, reverse=True)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _cap_per_category(constraints: List[Constraint], max_per: int = 5) -> List[Constraint]:
|
|
464
|
+
"""Cap constraints at max_per per category."""
|
|
465
|
+
counts: Dict[str, int] = {}
|
|
466
|
+
result = []
|
|
467
|
+
for c in constraints:
|
|
468
|
+
count = counts.get(c.category, 0)
|
|
469
|
+
if count < max_per:
|
|
470
|
+
result.append(c)
|
|
471
|
+
counts[c.category] = count + 1
|
|
472
|
+
return result
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Filter added lines to exclude comments and string literals.
|
|
2
|
+
|
|
3
|
+
Used by checks that apply regex patterns to raw diff lines.
|
|
4
|
+
Without filtering, patterns like .delete() would match in comments
|
|
5
|
+
and docstrings, causing false positives that block commits.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def strip_comments_and_strings(line: str) -> str:
|
|
13
|
+
"""Remove comments and string literals from a Python line.
|
|
14
|
+
|
|
15
|
+
Returns the code-only portion. Inline comments are stripped.
|
|
16
|
+
String contents are replaced with empty strings to preserve structure
|
|
17
|
+
but remove matchable content.
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
'x.delete() # remove it' -> 'x.delete() '
|
|
21
|
+
'# x.delete()' -> ''
|
|
22
|
+
'msg = "call .delete()"' -> 'msg = ""'
|
|
23
|
+
"x.delete()" -> "x.delete()"
|
|
24
|
+
"""
|
|
25
|
+
stripped = line.lstrip()
|
|
26
|
+
|
|
27
|
+
# Full-line comment
|
|
28
|
+
if stripped.startswith("#"):
|
|
29
|
+
return ""
|
|
30
|
+
|
|
31
|
+
# Replace string literals with empty strings (handles both ' and ")
|
|
32
|
+
# This is intentionally simple — not a full parser. Handles:
|
|
33
|
+
# "text", 'text', f"text", r"text", b"text"
|
|
34
|
+
# Does NOT handle triple-quoted strings spanning multiple lines
|
|
35
|
+
# (those are rare in single added-line diffs).
|
|
36
|
+
result = _replace_strings(line)
|
|
37
|
+
|
|
38
|
+
# Strip inline comments (# not inside a string)
|
|
39
|
+
comment_pos = _find_inline_comment(result)
|
|
40
|
+
if comment_pos >= 0:
|
|
41
|
+
result = result[:comment_pos]
|
|
42
|
+
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def filter_code_lines(lines: List[str]) -> List[str]:
|
|
47
|
+
"""Filter a list of added lines to only code content.
|
|
48
|
+
|
|
49
|
+
Removes full-line comments and strips string/comment content
|
|
50
|
+
from code lines. Returns lines that still have matchable code.
|
|
51
|
+
"""
|
|
52
|
+
filtered = []
|
|
53
|
+
for line in lines:
|
|
54
|
+
code = strip_comments_and_strings(line)
|
|
55
|
+
if code.strip():
|
|
56
|
+
filtered.append(code)
|
|
57
|
+
return filtered
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _replace_strings(line: str) -> str:
|
|
61
|
+
"""Replace string literal contents with empty strings."""
|
|
62
|
+
# Match f-strings, r-strings, b-strings, and plain strings
|
|
63
|
+
# Pattern: optional prefix + quote + non-greedy content + closing quote
|
|
64
|
+
result = re.sub(r'''(?:[fFrRbBuU]?)(""".*?"""|''' r"""'''.*?'''|"[^"\\]*(?:\\.[^"\\]*)*"|'[^'\\]*(?:\\.[^'\\]*)*')""", '""', line)
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _find_inline_comment(line: str) -> int:
|
|
69
|
+
"""Find position of inline # comment (not inside a string).
|
|
70
|
+
|
|
71
|
+
Returns -1 if no inline comment found.
|
|
72
|
+
"""
|
|
73
|
+
in_single = False
|
|
74
|
+
in_double = False
|
|
75
|
+
i = 0
|
|
76
|
+
while i < len(line):
|
|
77
|
+
c = line[i]
|
|
78
|
+
if c == "\\" and i + 1 < len(line):
|
|
79
|
+
i += 2 # Skip escaped character
|
|
80
|
+
continue
|
|
81
|
+
if c == '"' and not in_single:
|
|
82
|
+
in_double = not in_double
|
|
83
|
+
elif c == "'" and not in_double:
|
|
84
|
+
in_single = not in_single
|
|
85
|
+
elif c == "#" and not in_single and not in_double:
|
|
86
|
+
return i
|
|
87
|
+
i += 1
|
|
88
|
+
return -1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Data models for the enforcement system.
|
|
2
|
+
|
|
3
|
+
Backward-compatibility facade. All definitions now live in dotscope.models.intent.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from ...models.intent import ( # noqa: F401
|
|
7
|
+
Severity,
|
|
8
|
+
CheckCategory,
|
|
9
|
+
IntentDirective,
|
|
10
|
+
Constraint,
|
|
11
|
+
ConventionRule,
|
|
12
|
+
ProposedFix,
|
|
13
|
+
CheckResult,
|
|
14
|
+
CheckReport,
|
|
15
|
+
)
|