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,84 @@
1
+ """Lightweight timing instrumentation for core operations."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import List
8
+
9
+
10
+ @dataclass
11
+ class TimingEntry:
12
+ operation: str # "resolve", "check", "ingest"
13
+ duration_ms: float
14
+ timestamp: str
15
+
16
+
17
+ _MAX_TIMING_LINES = 5000
18
+
19
+
20
+ def record_timing(repo_root: str, operation: str, duration_ms: float) -> None:
21
+ """Append a timing entry to .dotscope/timings.jsonl. Truncates at 5000 lines."""
22
+ dot_dir = os.path.join(repo_root, ".dotscope")
23
+ if not os.path.isdir(dot_dir):
24
+ return # No .dotscope dir — skip silently
25
+
26
+ path = os.path.join(dot_dir, "timings.jsonl")
27
+ entry = {
28
+ "operation": operation,
29
+ "duration_ms": round(duration_ms, 2),
30
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
31
+ }
32
+ with open(path, "a", encoding="utf-8") as f:
33
+ f.write(json.dumps(entry) + "\n")
34
+
35
+ # Truncate if too large (keep most recent half)
36
+ try:
37
+ with open(path, "r", encoding="utf-8") as f:
38
+ lines = f.readlines()
39
+ if len(lines) > _MAX_TIMING_LINES:
40
+ with open(path, "w", encoding="utf-8") as f:
41
+ f.writelines(lines[len(lines) // 2:])
42
+ except (IOError, OSError):
43
+ pass
44
+
45
+
46
+ def load_timings(repo_root: str) -> List[TimingEntry]:
47
+ """Load all timing entries."""
48
+ path = os.path.join(repo_root, ".dotscope", "timings.jsonl")
49
+ if not os.path.exists(path):
50
+ return []
51
+
52
+ entries = []
53
+ with open(path, "r", encoding="utf-8") as f:
54
+ for line in f:
55
+ line = line.strip()
56
+ if line:
57
+ try:
58
+ d = json.loads(line)
59
+ entries.append(TimingEntry(
60
+ operation=d["operation"],
61
+ duration_ms=d["duration_ms"],
62
+ timestamp=d.get("timestamp", ""),
63
+ ))
64
+ except (json.JSONDecodeError, KeyError):
65
+ continue
66
+ return entries
67
+
68
+
69
+ def median(values: List[float]) -> float:
70
+ if not values:
71
+ return 0.0
72
+ s = sorted(values)
73
+ n = len(s)
74
+ if n % 2 == 0:
75
+ return (s[n // 2 - 1] + s[n // 2]) / 2
76
+ return s[n // 2]
77
+
78
+
79
+ def percentile(values: List[float], p: int) -> float:
80
+ if not values:
81
+ return 0.0
82
+ s = sorted(values)
83
+ idx = int(len(s) * p / 100)
84
+ return s[min(idx, len(s) - 1)]
dotscope/timing.py ADDED
@@ -0,0 +1,2 @@
1
+ """Backward-compatibility stub. Moved to dotscope.storage.timing."""
2
+ from .storage.timing import * # noqa: F401,F403
dotscope/tokens.py ADDED
@@ -0,0 +1,53 @@
1
+ """Token estimation for scope files and resolved contexts.
2
+
3
+ Uses tiktoken if available, otherwise falls back to len(text) // 4.
4
+ """
5
+
6
+
7
+ import os
8
+ from typing import List
9
+
10
+ # Try tiktoken for accurate counts, fall back to approximation
11
+ try:
12
+ import tiktoken
13
+
14
+ _encoder = tiktoken.encoding_for_model("gpt-4")
15
+
16
+ def estimate_tokens(text: str) -> int:
17
+ """Estimate token count using tiktoken."""
18
+ return len(_encoder.encode(text))
19
+
20
+ except ImportError:
21
+
22
+ def estimate_tokens(text: str) -> int:
23
+ """Estimate token count (~4 chars per token for English)."""
24
+ return len(text) // 4
25
+
26
+
27
+ def estimate_file_tokens(path: str) -> int:
28
+ """Read a file and estimate its token count."""
29
+ try:
30
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
31
+ return estimate_tokens(f.read())
32
+ except (OSError, IOError):
33
+ return 0
34
+
35
+
36
+ def estimate_scope_tokens(files: List[str]) -> int:
37
+ """Estimate total tokens across a list of files."""
38
+ return sum(estimate_file_tokens(f) for f in files)
39
+
40
+
41
+ def estimate_context_tokens(context: str) -> int:
42
+ """Estimate tokens for a context string."""
43
+ if not context:
44
+ return 0
45
+ return estimate_tokens(context)
46
+
47
+
48
+ def file_size_bytes(path: str) -> int:
49
+ """Get file size in bytes, 0 if not accessible."""
50
+ try:
51
+ return os.path.getsize(path)
52
+ except OSError:
53
+ return 0
dotscope/utility.py ADDED
@@ -0,0 +1,123 @@
1
+ """Utility scoring: historical file relevance from observation data.
2
+
3
+ Computes per-file utility ratios from session + observation logs.
4
+ Files agents actually touch get higher scores. Budget allocation
5
+ uses these scores instead of static heuristics.
6
+
7
+ The utility floor prevents a death spiral: core abstractions that are
8
+ rarely edited but frequently read always retain a base weight.
9
+ """
10
+
11
+ import json
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional
15
+
16
+ from .models.state import FileUtilityScore, ObservationLog, SessionLog # noqa: F401
17
+
18
+
19
+ BASE_UTILITY_WEIGHT = 0.5
20
+ MAX_UTILITY_BONUS = 1.0
21
+ MIN_SAMPLE_SIZE = 3
22
+
23
+
24
+ def compute_utility_scores(
25
+ sessions: List[SessionLog],
26
+ observations: List[ObservationLog],
27
+ ) -> Dict[str, FileUtilityScore]:
28
+ """Build utility scores from session + observation logs."""
29
+ scores: Dict[str, FileUtilityScore] = {}
30
+
31
+ # Count resolves per file
32
+ for session in sessions:
33
+ for f in session.predicted_files:
34
+ if f not in scores:
35
+ scores[f] = FileUtilityScore(path=f)
36
+ scores[f].resolve_count += 1
37
+ scores[f].last_resolved = max(scores[f].last_resolved, session.timestamp)
38
+
39
+ # Count touches per file
40
+ obs_by_session: Dict[str, ObservationLog] = {
41
+ obs.session_id: obs for obs in observations
42
+ }
43
+ for session in sessions:
44
+ obs = obs_by_session.get(session.session_id)
45
+ if not obs:
46
+ continue
47
+ for f in obs.actual_files_modified:
48
+ if f not in scores:
49
+ scores[f] = FileUtilityScore(path=f)
50
+ scores[f].touch_count += 1
51
+ scores[f].last_touched = max(scores[f].last_touched, obs.timestamp)
52
+
53
+ # Compute ratios
54
+ for score in scores.values():
55
+ if score.resolve_count > 0:
56
+ score.utility_ratio = round(score.touch_count / score.resolve_count, 3)
57
+
58
+ return scores
59
+
60
+
61
+ def effective_score(
62
+ base_score: float,
63
+ utility: Optional[FileUtilityScore],
64
+ is_explicit_include: bool,
65
+ ) -> float:
66
+ """Compute effective score with utility floor protection.
67
+
68
+ Explicit includes always get a base weight. Utility observations
69
+ add on top, never subtract below the floor.
70
+ """
71
+ floor = BASE_UTILITY_WEIGHT if is_explicit_include else 0.0
72
+
73
+ bonus = 0.0
74
+ if utility and utility.resolve_count >= MIN_SAMPLE_SIZE:
75
+ bonus = utility.utility_ratio * MAX_UTILITY_BONUS
76
+ # Recency bonus: touched in last 30 days
77
+ if utility.last_touched > time.time() - (30 * 86400):
78
+ bonus *= 1.1
79
+
80
+ return base_score * max(floor + bonus, floor) if floor else base_score * (1.0 + bonus)
81
+
82
+
83
+ def save_utility_scores(dot_dir: Path, scores: Dict[str, FileUtilityScore]) -> None:
84
+ """Write utility scores to .dotscope/utility/file_scores.json."""
85
+ utility_dir = dot_dir / "utility"
86
+ utility_dir.mkdir(parents=True, exist_ok=True)
87
+
88
+ data = {
89
+ path: {
90
+ "resolve_count": s.resolve_count,
91
+ "touch_count": s.touch_count,
92
+ "utility_ratio": s.utility_ratio,
93
+ "last_touched": s.last_touched,
94
+ "last_resolved": s.last_resolved,
95
+ }
96
+ for path, s in scores.items()
97
+ }
98
+
99
+ (utility_dir / "file_scores.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
100
+
101
+
102
+ def load_utility_scores(dot_dir: Path) -> Dict[str, FileUtilityScore]:
103
+ """Load utility scores from .dotscope/utility/file_scores.json."""
104
+ path = dot_dir / "utility" / "file_scores.json"
105
+ if not path.exists():
106
+ return {}
107
+
108
+ try:
109
+ data = json.loads(path.read_text(encoding="utf-8"))
110
+ return {
111
+ k: FileUtilityScore(path=k, **v)
112
+ for k, v in data.items()
113
+ }
114
+ except (json.JSONDecodeError, TypeError):
115
+ return {}
116
+
117
+
118
+ def rebuild_utility(dot_dir: Path, sessions: List[SessionLog],
119
+ observations: List[ObservationLog]) -> Dict[str, FileUtilityScore]:
120
+ """Rebuild and save utility scores from event logs."""
121
+ scores = compute_utility_scores(sessions, observations)
122
+ save_utility_scores(dot_dir, scores)
123
+ return scores
dotscope/virtual.py ADDED
@@ -0,0 +1,3 @@
1
+ """Backward-compatibility stub. Moved to dotscope.passes.virtual."""
2
+ from .passes.virtual import * # noqa: F401,F403
3
+ from .models.passes import VirtualScope # noqa: F401