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