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
dotscope/discovery.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Discover repo roots, .scope files, and .scopes index files."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from .constants import SKIP_DIRS
|
|
8
|
+
from .models import ScopeConfig, ScopesIndex
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def find_repo_root(start_dir: Optional[str] = None) -> Optional[str]:
|
|
12
|
+
"""Walk up from start_dir looking for .scopes, .git, or a .scope file.
|
|
13
|
+
|
|
14
|
+
Returns the directory containing the marker, or None.
|
|
15
|
+
"""
|
|
16
|
+
current = os.path.abspath(start_dir or os.getcwd())
|
|
17
|
+
|
|
18
|
+
while True:
|
|
19
|
+
if os.path.isfile(os.path.join(current, ".scopes")):
|
|
20
|
+
return current
|
|
21
|
+
if os.path.isdir(os.path.join(current, ".git")):
|
|
22
|
+
return current
|
|
23
|
+
if os.path.isfile(os.path.join(current, ".scope")):
|
|
24
|
+
return current
|
|
25
|
+
|
|
26
|
+
parent = os.path.dirname(current)
|
|
27
|
+
if parent == current:
|
|
28
|
+
break
|
|
29
|
+
current = parent
|
|
30
|
+
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_all_scopes(root: str) -> List[str]:
|
|
35
|
+
"""Find all .scope files under root, returning absolute paths.
|
|
36
|
+
|
|
37
|
+
Skips common directories that should never be scanned.
|
|
38
|
+
"""
|
|
39
|
+
scope_files = []
|
|
40
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
41
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
42
|
+
|
|
43
|
+
if ".scope" in filenames:
|
|
44
|
+
scope_files.append(os.path.join(dirpath, ".scope"))
|
|
45
|
+
|
|
46
|
+
return sorted(scope_files)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_index(root: str) -> Optional[ScopesIndex]:
|
|
50
|
+
"""Load .scopes index from repo root, if it exists."""
|
|
51
|
+
from .parser import parse_scopes_index
|
|
52
|
+
|
|
53
|
+
index_path = os.path.join(root, ".scopes")
|
|
54
|
+
if os.path.isfile(index_path):
|
|
55
|
+
return parse_scopes_index(index_path)
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_scope(name_or_path: str, root: Optional[str] = None) -> Optional[ScopeConfig]:
|
|
60
|
+
"""Resolve a scope by name (from index), path, or directory.
|
|
61
|
+
|
|
62
|
+
Resolution order:
|
|
63
|
+
1. If name_or_path is a file path ending in .scope, parse it directly
|
|
64
|
+
2. If name_or_path is a directory containing .scope, parse that
|
|
65
|
+
3. Look up in .scopes index by name
|
|
66
|
+
4. Look for name_or_path/.scope relative to root
|
|
67
|
+
"""
|
|
68
|
+
from .parser import parse_scope_file
|
|
69
|
+
|
|
70
|
+
# Direct .scope file path
|
|
71
|
+
if name_or_path.endswith(".scope") and os.path.isfile(name_or_path):
|
|
72
|
+
return parse_scope_file(name_or_path)
|
|
73
|
+
|
|
74
|
+
# Absolute directory containing .scope
|
|
75
|
+
if os.path.isdir(name_or_path):
|
|
76
|
+
scope_path = os.path.join(name_or_path, ".scope")
|
|
77
|
+
if os.path.isfile(scope_path):
|
|
78
|
+
return parse_scope_file(scope_path)
|
|
79
|
+
|
|
80
|
+
# Need root for index and relative lookups
|
|
81
|
+
if root is None:
|
|
82
|
+
root = find_repo_root()
|
|
83
|
+
if root is None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
# Check .scopes index
|
|
87
|
+
index = load_index(root)
|
|
88
|
+
if index and name_or_path in index.scopes:
|
|
89
|
+
entry = index.scopes[name_or_path]
|
|
90
|
+
scope_path = os.path.join(root, entry.path)
|
|
91
|
+
if os.path.isfile(scope_path):
|
|
92
|
+
return parse_scope_file(scope_path)
|
|
93
|
+
|
|
94
|
+
# Try as relative directory
|
|
95
|
+
candidate = os.path.join(root, name_or_path, ".scope")
|
|
96
|
+
if os.path.isfile(candidate):
|
|
97
|
+
return parse_scope_file(candidate)
|
|
98
|
+
|
|
99
|
+
# Try as relative path to .scope
|
|
100
|
+
candidate = os.path.join(root, name_or_path)
|
|
101
|
+
if candidate.endswith(".scope") and os.path.isfile(candidate):
|
|
102
|
+
return parse_scope_file(candidate)
|
|
103
|
+
|
|
104
|
+
return None
|
dotscope/formatter.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Output formatting: plain, json, cursor."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from .models import ResolvedScope
|
|
8
|
+
from .paths import make_relative
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_resolved(
|
|
12
|
+
resolved: ResolvedScope,
|
|
13
|
+
fmt: str = "plain",
|
|
14
|
+
root: Optional[str] = None,
|
|
15
|
+
show_tokens: bool = False,
|
|
16
|
+
) -> str:
|
|
17
|
+
"""Format a resolved scope for output.
|
|
18
|
+
|
|
19
|
+
Formats:
|
|
20
|
+
plain — One file path per line, with comments for context/excludes
|
|
21
|
+
json — Full JSON object
|
|
22
|
+
cursor — .cursorrules-style: context + file list for pasting into agent prompts
|
|
23
|
+
"""
|
|
24
|
+
if fmt == "json":
|
|
25
|
+
return _format_json(resolved, root)
|
|
26
|
+
elif fmt == "cursor":
|
|
27
|
+
return _format_cursor(resolved, root)
|
|
28
|
+
else:
|
|
29
|
+
return _format_plain(resolved, root, show_tokens)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _format_plain(resolved: ResolvedScope, root: Optional[str], show_tokens: bool) -> str:
|
|
33
|
+
"""Plain format: one file per line."""
|
|
34
|
+
lines = []
|
|
35
|
+
|
|
36
|
+
for f in resolved.files:
|
|
37
|
+
path = make_relative(f, root)
|
|
38
|
+
if show_tokens:
|
|
39
|
+
from .tokens import estimate_file_tokens
|
|
40
|
+
tokens = estimate_file_tokens(f)
|
|
41
|
+
lines.append(f"{path} # {tokens} tokens")
|
|
42
|
+
else:
|
|
43
|
+
lines.append(path)
|
|
44
|
+
|
|
45
|
+
if resolved.excluded_files:
|
|
46
|
+
lines.append("")
|
|
47
|
+
lines.append(f"# Excluded: {len(resolved.excluded_files)} files")
|
|
48
|
+
|
|
49
|
+
if resolved.truncated:
|
|
50
|
+
lines.append(f"# Truncated to fit token budget ({resolved.token_estimate} tokens)")
|
|
51
|
+
|
|
52
|
+
if resolved.context:
|
|
53
|
+
lines.append("")
|
|
54
|
+
lines.append(f"# Context: {len(resolved.context)} chars, from {len(resolved.scope_chain)} scope(s)")
|
|
55
|
+
|
|
56
|
+
return "\n".join(lines)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _format_json(resolved: ResolvedScope, root: Optional[str]) -> str:
|
|
60
|
+
"""JSON format: full object."""
|
|
61
|
+
data = {
|
|
62
|
+
"files": [make_relative(f, root) for f in resolved.files],
|
|
63
|
+
"context": resolved.context,
|
|
64
|
+
"token_estimate": resolved.token_estimate,
|
|
65
|
+
"scope_chain": [make_relative(s, root) for s in resolved.scope_chain],
|
|
66
|
+
"truncated": resolved.truncated,
|
|
67
|
+
"file_count": len(resolved.files),
|
|
68
|
+
}
|
|
69
|
+
if resolved.excluded_files:
|
|
70
|
+
data["excluded_count"] = len(resolved.excluded_files)
|
|
71
|
+
|
|
72
|
+
return json.dumps(data, indent=2)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _format_cursor(resolved: ResolvedScope, root: Optional[str]) -> str:
|
|
76
|
+
"""Cursor-style format for pasting into agent prompts or .cursorrules."""
|
|
77
|
+
parts = []
|
|
78
|
+
|
|
79
|
+
if resolved.context:
|
|
80
|
+
parts.append("# Scope Context")
|
|
81
|
+
parts.append("")
|
|
82
|
+
parts.append(resolved.context)
|
|
83
|
+
parts.append("")
|
|
84
|
+
|
|
85
|
+
if resolved.files:
|
|
86
|
+
parts.append("# Relevant Files")
|
|
87
|
+
parts.append("")
|
|
88
|
+
for f in resolved.files:
|
|
89
|
+
parts.append(f"- {make_relative(f, root)}")
|
|
90
|
+
|
|
91
|
+
if resolved.truncated:
|
|
92
|
+
parts.append("")
|
|
93
|
+
parts.append(f"# Note: file list truncated to {resolved.token_estimate} tokens")
|
|
94
|
+
|
|
95
|
+
return "\n".join(parts)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def format_stats(
|
|
99
|
+
scope_stats: list,
|
|
100
|
+
total_files: int,
|
|
101
|
+
total_tokens: int,
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Format the stats report."""
|
|
104
|
+
lines = [
|
|
105
|
+
f"Repository: {total_files} files, ~{total_tokens:,} tokens",
|
|
106
|
+
"",
|
|
107
|
+
f"{'Scope':<20} {'Files':>6} {'Tokens':>8} {'Savings':>8}",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
for name, file_count, token_count in scope_stats:
|
|
111
|
+
if total_tokens > 0:
|
|
112
|
+
savings = (1 - token_count / total_tokens) * 100
|
|
113
|
+
lines.append(f"{name:<20} {file_count:>6} {token_count:>8,} {savings:>7.1f}%")
|
|
114
|
+
else:
|
|
115
|
+
lines.append(f"{name:<20} {file_count:>6} {token_count:>8,} N/A")
|
|
116
|
+
|
|
117
|
+
if scope_stats and total_tokens > 0:
|
|
118
|
+
avg_savings = sum(
|
|
119
|
+
(1 - tc / total_tokens) * 100 for _, _, tc in scope_stats
|
|
120
|
+
) / len(scope_stats)
|
|
121
|
+
lines.append("")
|
|
122
|
+
lines.append(f"Average context reduction: {avg_savings:.1f}%")
|
|
123
|
+
|
|
124
|
+
avg_tokens = sum(tc for _, _, tc in scope_stats) / len(scope_stats)
|
|
125
|
+
cost_per_call = (total_tokens - avg_tokens) / 1_000_000 * 3
|
|
126
|
+
lines.append(f"Estimated cost savings at $3/M tokens: ${cost_per_call:.2f} per agent call")
|
|
127
|
+
|
|
128
|
+
return "\n".join(lines)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def format_tree(scopes: list, root: str) -> str:
|
|
132
|
+
"""Format a visual tree of scopes and relationships."""
|
|
133
|
+
if not scopes:
|
|
134
|
+
return "No .scope files found."
|
|
135
|
+
|
|
136
|
+
lines = [os.path.basename(root) + "/"]
|
|
137
|
+
|
|
138
|
+
for i, (scope_path, config) in enumerate(scopes):
|
|
139
|
+
is_last = i == len(scopes) - 1
|
|
140
|
+
prefix = "└── " if is_last else "├── "
|
|
141
|
+
rel = os.path.relpath(scope_path, root)
|
|
142
|
+
desc = config.description if config else "?"
|
|
143
|
+
|
|
144
|
+
lines.append(f"{prefix}{rel}")
|
|
145
|
+
lines.append(f"{' ' if is_last else '│ '} {desc}")
|
|
146
|
+
|
|
147
|
+
if config and config.related:
|
|
148
|
+
for j, related in enumerate(config.related):
|
|
149
|
+
r_is_last = j == len(config.related) - 1
|
|
150
|
+
r_prefix = " └─→ " if r_is_last else " ├─→ "
|
|
151
|
+
if not is_last:
|
|
152
|
+
r_prefix = "│ " + r_prefix[4:]
|
|
153
|
+
lines.append(f"{r_prefix}{related}")
|
|
154
|
+
|
|
155
|
+
return "\n".join(lines)
|
|
156
|
+
|
|
157
|
+
|
dotscope/graph.py
ADDED
dotscope/health.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Scope health monitoring: staleness, coverage gaps, import drift."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from typing import List, Optional, Set
|
|
7
|
+
|
|
8
|
+
from .constants import SKIP_DIRS, SOURCE_EXTS
|
|
9
|
+
from .models import HealthIssue, HealthReport, ScopeConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def full_health_report(root: str) -> HealthReport:
|
|
13
|
+
"""Run all health checks and return a combined report."""
|
|
14
|
+
from .discovery import find_all_scopes
|
|
15
|
+
from .parser import parse_scope_file
|
|
16
|
+
|
|
17
|
+
scope_files = find_all_scopes(root)
|
|
18
|
+
issues: List[HealthIssue] = []
|
|
19
|
+
scoped_dirs: Set[str] = set()
|
|
20
|
+
|
|
21
|
+
for sf in scope_files:
|
|
22
|
+
try:
|
|
23
|
+
config = parse_scope_file(sf)
|
|
24
|
+
except (ValueError, IOError) as e:
|
|
25
|
+
issues.append(HealthIssue(
|
|
26
|
+
scope_path=sf, severity="error",
|
|
27
|
+
category="parse", message=str(e),
|
|
28
|
+
))
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
scoped_dirs.add(os.path.dirname(sf))
|
|
32
|
+
|
|
33
|
+
issues.extend(check_staleness(config, root))
|
|
34
|
+
issues.extend(check_broken_paths(config, root))
|
|
35
|
+
issues.extend(check_import_drift(config))
|
|
36
|
+
|
|
37
|
+
# Coverage check
|
|
38
|
+
all_dirs = _find_source_dirs(root)
|
|
39
|
+
uncovered = all_dirs - scoped_dirs
|
|
40
|
+
coverage_issues = check_coverage(uncovered, root)
|
|
41
|
+
issues.extend(coverage_issues)
|
|
42
|
+
|
|
43
|
+
return HealthReport(
|
|
44
|
+
issues=issues,
|
|
45
|
+
scopes_checked=len(scope_files),
|
|
46
|
+
directories_total=len(all_dirs),
|
|
47
|
+
directories_covered=len(scoped_dirs),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_staleness(config: ScopeConfig, root: str) -> List[HealthIssue]:
|
|
52
|
+
"""Check if files in scope have been modified more recently than the .scope file."""
|
|
53
|
+
issues = []
|
|
54
|
+
scope_mtime = _get_mtime(config.path)
|
|
55
|
+
if scope_mtime is None:
|
|
56
|
+
return issues
|
|
57
|
+
|
|
58
|
+
stale_files = []
|
|
59
|
+
|
|
60
|
+
for inc in config.includes:
|
|
61
|
+
full = os.path.normpath(os.path.join(root, inc))
|
|
62
|
+
if inc.endswith("/") or os.path.isdir(full.rstrip("/")):
|
|
63
|
+
dir_path = full.rstrip("/")
|
|
64
|
+
if os.path.isdir(dir_path):
|
|
65
|
+
for dirpath, _, filenames in os.walk(dir_path):
|
|
66
|
+
for fn in filenames:
|
|
67
|
+
fp = os.path.join(dirpath, fn)
|
|
68
|
+
fmtime = _get_mtime(fp)
|
|
69
|
+
if fmtime and fmtime > scope_mtime:
|
|
70
|
+
stale_files.append(os.path.relpath(fp, root))
|
|
71
|
+
elif os.path.isfile(full):
|
|
72
|
+
fmtime = _get_mtime(full)
|
|
73
|
+
if fmtime and fmtime > scope_mtime:
|
|
74
|
+
stale_files.append(os.path.relpath(full, root))
|
|
75
|
+
|
|
76
|
+
if stale_files:
|
|
77
|
+
count = len(stale_files)
|
|
78
|
+
sample = stale_files[:3]
|
|
79
|
+
msg = f"{count} file(s) modified since .scope was last updated: {', '.join(sample)}"
|
|
80
|
+
if count > 3:
|
|
81
|
+
msg += f" (+{count - 3} more)"
|
|
82
|
+
issues.append(HealthIssue(
|
|
83
|
+
scope_path=config.path, severity="warning",
|
|
84
|
+
category="staleness", message=msg,
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
return issues
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check_broken_paths(config: ScopeConfig, root: str = "") -> List[HealthIssue]:
|
|
91
|
+
"""Check for include/related paths that don't exist."""
|
|
92
|
+
from .paths import path_exists, strip_inline_comment
|
|
93
|
+
|
|
94
|
+
issues = []
|
|
95
|
+
scope_dir = config.directory
|
|
96
|
+
base = root or scope_dir
|
|
97
|
+
|
|
98
|
+
for inc in config.includes:
|
|
99
|
+
if not path_exists(base, inc):
|
|
100
|
+
issues.append(HealthIssue(
|
|
101
|
+
scope_path=config.path, severity="error",
|
|
102
|
+
category="broken_path", message=f"include not found: {inc}",
|
|
103
|
+
))
|
|
104
|
+
|
|
105
|
+
for rel in config.related:
|
|
106
|
+
clean = strip_inline_comment(rel)
|
|
107
|
+
# Related paths are repo-root-relative; try root first, then scope dir
|
|
108
|
+
if not path_exists(base, clean) and not path_exists(scope_dir, clean):
|
|
109
|
+
issues.append(HealthIssue(
|
|
110
|
+
scope_path=config.path, severity="warning",
|
|
111
|
+
category="broken_path", message=f"related scope not found: {clean}",
|
|
112
|
+
))
|
|
113
|
+
|
|
114
|
+
return issues
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check_import_drift(config: ScopeConfig) -> List[HealthIssue]:
|
|
118
|
+
"""Check if imports in scoped files reference modules not in includes."""
|
|
119
|
+
issues = []
|
|
120
|
+
scope_dir = config.directory
|
|
121
|
+
included_dirs = set()
|
|
122
|
+
|
|
123
|
+
for inc in config.includes:
|
|
124
|
+
full = os.path.normpath(os.path.join(scope_dir, inc))
|
|
125
|
+
if inc.endswith("/") or os.path.isdir(full.rstrip("/")):
|
|
126
|
+
included_dirs.add(os.path.basename(full.rstrip("/")))
|
|
127
|
+
else:
|
|
128
|
+
included_dirs.add(os.path.dirname(inc).split("/")[0] if "/" in inc else "")
|
|
129
|
+
|
|
130
|
+
# Find Python files in includes and check their imports
|
|
131
|
+
drifted = set()
|
|
132
|
+
parent = os.path.dirname(scope_dir)
|
|
133
|
+
|
|
134
|
+
for inc in config.includes:
|
|
135
|
+
full = os.path.normpath(os.path.join(scope_dir, inc))
|
|
136
|
+
files = []
|
|
137
|
+
if os.path.isdir(full.rstrip("/")):
|
|
138
|
+
for dp, _, fns in os.walk(full.rstrip("/")):
|
|
139
|
+
for fn in fns:
|
|
140
|
+
if fn.endswith(".py"):
|
|
141
|
+
files.append(os.path.join(dp, fn))
|
|
142
|
+
elif full.endswith(".py") and os.path.isfile(full):
|
|
143
|
+
files.append(full)
|
|
144
|
+
|
|
145
|
+
for f in files:
|
|
146
|
+
try:
|
|
147
|
+
with open(f, "r", encoding="utf-8", errors="replace") as fh:
|
|
148
|
+
for line in fh:
|
|
149
|
+
m = re.match(r"from\s+([\w.]+)\s+import", line.strip())
|
|
150
|
+
if m:
|
|
151
|
+
module = m.group(1).split(".")[0]
|
|
152
|
+
candidate = os.path.join(parent, module)
|
|
153
|
+
if (
|
|
154
|
+
os.path.isdir(candidate)
|
|
155
|
+
and module not in included_dirs
|
|
156
|
+
and candidate != scope_dir
|
|
157
|
+
and module not in SKIP_DIRS
|
|
158
|
+
):
|
|
159
|
+
drifted.add(module)
|
|
160
|
+
except (IOError, OSError):
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if drifted:
|
|
164
|
+
msg = f"imports reference modules not in includes: {', '.join(sorted(drifted))}"
|
|
165
|
+
issues.append(HealthIssue(
|
|
166
|
+
scope_path=config.path, severity="info",
|
|
167
|
+
category="drift", message=msg,
|
|
168
|
+
))
|
|
169
|
+
|
|
170
|
+
return issues
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check_coverage(uncovered: Set[str], root: str) -> List[HealthIssue]:
|
|
174
|
+
"""Report directories that have source files but no .scope."""
|
|
175
|
+
issues = []
|
|
176
|
+
for d in sorted(uncovered):
|
|
177
|
+
rel = os.path.relpath(d, root)
|
|
178
|
+
# Only report if directory has source files
|
|
179
|
+
has_source = any(
|
|
180
|
+
fn.endswith((".py", ".js", ".ts", ".go", ".rs", ".rb", ".java"))
|
|
181
|
+
for fn in os.listdir(d)
|
|
182
|
+
if os.path.isfile(os.path.join(d, fn))
|
|
183
|
+
)
|
|
184
|
+
if has_source:
|
|
185
|
+
issues.append(HealthIssue(
|
|
186
|
+
scope_path="", severity="info",
|
|
187
|
+
category="coverage", message=f"no .scope file: {rel}/",
|
|
188
|
+
))
|
|
189
|
+
|
|
190
|
+
return issues
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _find_source_dirs(root: str) -> Set[str]:
|
|
194
|
+
"""Find all directories under root that contain source files."""
|
|
195
|
+
source_exts = SOURCE_EXTS
|
|
196
|
+
dirs = set()
|
|
197
|
+
|
|
198
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
199
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
200
|
+
|
|
201
|
+
if any(os.path.splitext(f)[1] in source_exts for f in filenames):
|
|
202
|
+
dirs.add(dirpath)
|
|
203
|
+
|
|
204
|
+
return dirs
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _get_mtime(path: str) -> Optional[float]:
|
|
208
|
+
"""Get file modification time, None if not accessible."""
|
|
209
|
+
try:
|
|
210
|
+
return os.path.getmtime(path)
|
|
211
|
+
except OSError:
|
|
212
|
+
return None
|
dotscope/help.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Hand-written help text following the voice spec.
|
|
2
|
+
|
|
3
|
+
Dense, no filler, examples before options. argparse handles parsing;
|
|
4
|
+
this module handles display.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
HELP_ROOT = """\
|
|
8
|
+
Usage: dotscope <command>
|
|
9
|
+
|
|
10
|
+
init One command: ingest, hooks, MCP config
|
|
11
|
+
resolve <scope> Serve context to an agent
|
|
12
|
+
check Verify routing (guards block, nudges guide)
|
|
13
|
+
conventions View and manage conventions
|
|
14
|
+
voice View and manage code style
|
|
15
|
+
health Scope staleness and drift
|
|
16
|
+
|
|
17
|
+
ingest . Re-ingest (full scan)
|
|
18
|
+
intent Declare architectural direction
|
|
19
|
+
diff --staged Semantic diff
|
|
20
|
+
hook install Re-install hooks
|
|
21
|
+
bench Performance metrics
|
|
22
|
+
test-compiler Regression suite
|
|
23
|
+
debug --last Diagnose a bad session
|
|
24
|
+
|
|
25
|
+
Run dotscope <command> --help for details."""
|
|
26
|
+
|
|
27
|
+
HELP_INGEST = """\
|
|
28
|
+
Usage: dotscope ingest <path> [options]
|
|
29
|
+
|
|
30
|
+
dotscope ingest . Full codebase ingest
|
|
31
|
+
dotscope ingest auth/ Single module
|
|
32
|
+
dotscope ingest . --max-commits 500 Deeper history mining
|
|
33
|
+
dotscope ingest . --quiet Suppress progress (for CI)
|
|
34
|
+
|
|
35
|
+
Options:
|
|
36
|
+
--max-commits <n> Git commits to mine (default: 500)
|
|
37
|
+
--no-history Skip git history mining
|
|
38
|
+
--no-docs Skip doc absorption
|
|
39
|
+
--quiet Suppress progress output
|
|
40
|
+
--dry-run Show what would be generated without writing"""
|
|
41
|
+
|
|
42
|
+
HELP_RESOLVE = """\
|
|
43
|
+
Usage: dotscope resolve <scope> [options]
|
|
44
|
+
|
|
45
|
+
dotscope resolve auth Files and context for auth/
|
|
46
|
+
dotscope resolve auth --budget 4000 Best 4K tokens
|
|
47
|
+
dotscope resolve auth+payments Union of two scopes
|
|
48
|
+
dotscope resolve auth@context Context only, no files
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
--budget <tokens> Token limit
|
|
52
|
+
--task <description> Filter constraints by relevance
|
|
53
|
+
--json Machine-readable output
|
|
54
|
+
--no-related Don't follow related scopes"""
|
|
55
|
+
|
|
56
|
+
HELP_CHECK = """\
|
|
57
|
+
Usage: dotscope check [options]
|
|
58
|
+
|
|
59
|
+
dotscope check Validate staged changes
|
|
60
|
+
dotscope check --diff changes.patch Check arbitrary diff
|
|
61
|
+
dotscope check --backtest --commits 10 Replay history
|
|
62
|
+
dotscope check --acknowledge <id> Acknowledge a guard
|
|
63
|
+
|
|
64
|
+
Three severities:
|
|
65
|
+
GUARD Blocks commit. Frozen modules, deprecated imports.
|
|
66
|
+
NUDGE Prints guidance. Contracts, conventions, anti-patterns.
|
|
67
|
+
NOTE Informational. Direction reversals, stability.
|
|
68
|
+
|
|
69
|
+
Options:
|
|
70
|
+
--staged Check staged changes (default)
|
|
71
|
+
--diff <file> Check arbitrary diff file
|
|
72
|
+
--backtest Replay commits instead of checking staged
|
|
73
|
+
--commits <n> Commits to replay (with --backtest)
|
|
74
|
+
--acknowledge <id> Acknowledge a guard and proceed
|
|
75
|
+
--json Machine-readable output"""
|
|
76
|
+
|
|
77
|
+
HELP_INTENT = """\
|
|
78
|
+
Usage: dotscope intent <action> [options]
|
|
79
|
+
|
|
80
|
+
dotscope intent list Show all intents
|
|
81
|
+
dotscope intent add decouple auth/ payments/ Decouple modules
|
|
82
|
+
dotscope intent add freeze core/ Freeze a module
|
|
83
|
+
dotscope intent add deprecate old.py --replacement new.py
|
|
84
|
+
dotscope intent remove <id> Remove an intent
|
|
85
|
+
|
|
86
|
+
Directives:
|
|
87
|
+
decouple <mod> <mod> Discourage new coupling between modules
|
|
88
|
+
deprecate <file> Flag new usage as a hold
|
|
89
|
+
freeze <module> Require acknowledgment for any change
|
|
90
|
+
consolidate <m> <m> Encourage merging toward a target"""
|
|
91
|
+
|
|
92
|
+
HELP_CONVENTIONS = """\
|
|
93
|
+
Usage: dotscope conventions [options]
|
|
94
|
+
|
|
95
|
+
dotscope conventions List all conventions + compliance
|
|
96
|
+
dotscope conventions --discover Re-run discovery against codebase
|
|
97
|
+
dotscope conventions --accept Accept all discovered conventions
|
|
98
|
+
dotscope conventions --review Interactive review
|
|
99
|
+
|
|
100
|
+
Options:
|
|
101
|
+
--discover Re-run convention discovery
|
|
102
|
+
--accept Accept discovered conventions
|
|
103
|
+
--review Review discovered conventions interactively"""
|
|
104
|
+
|
|
105
|
+
HELP_VOICE = """\
|
|
106
|
+
Usage: dotscope voice [options]
|
|
107
|
+
|
|
108
|
+
dotscope voice Show current voice config
|
|
109
|
+
dotscope voice --upgrade typing Upgrade enforcement level
|
|
110
|
+
dotscope voice --json Machine-readable output
|
|
111
|
+
|
|
112
|
+
Options:
|
|
113
|
+
--upgrade <rule> Upgrade enforcement (typing, bare_excepts)
|
|
114
|
+
--json Machine-readable output"""
|
|
115
|
+
|
|
116
|
+
HELP_DIFF = """\
|
|
117
|
+
Usage: dotscope diff [options]
|
|
118
|
+
|
|
119
|
+
dotscope diff --staged Semantic diff of staged changes
|
|
120
|
+
dotscope diff HEAD~1 Semantic diff against last commit
|
|
121
|
+
dotscope diff HEAD~5..HEAD Range of commits
|
|
122
|
+
|
|
123
|
+
Options:
|
|
124
|
+
--staged Diff staged changes (default)
|
|
125
|
+
--json Machine-readable output"""
|
|
126
|
+
|
|
127
|
+
HELP_BENCH = """\
|
|
128
|
+
Usage: dotscope bench [options]
|
|
129
|
+
|
|
130
|
+
dotscope bench Full benchmark report
|
|
131
|
+
dotscope bench --json Machine-readable output
|
|
132
|
+
|
|
133
|
+
Options:
|
|
134
|
+
--json Machine-readable output"""
|
|
135
|
+
|
|
136
|
+
HELP_TEST_COMPILER = """\
|
|
137
|
+
Usage: dotscope test-compiler [options]
|
|
138
|
+
|
|
139
|
+
dotscope test-compiler Replay all regression cases
|
|
140
|
+
dotscope test-compiler --scope auth Replay regressions for auth only
|
|
141
|
+
|
|
142
|
+
Options:
|
|
143
|
+
--scope <name> Filter to a specific scope"""
|
|
144
|
+
|
|
145
|
+
HELP_DEBUG = """\
|
|
146
|
+
Usage: dotscope debug [options]
|
|
147
|
+
|
|
148
|
+
dotscope debug --last Debug most recent bad session
|
|
149
|
+
dotscope debug <session_id> Debug a specific session
|
|
150
|
+
dotscope debug --list List sessions with low recall
|
|
151
|
+
|
|
152
|
+
Options:
|
|
153
|
+
--last Debug most recent session with low recall
|
|
154
|
+
--list List debuggable sessions
|
|
155
|
+
--json Machine-readable output"""
|
|
156
|
+
|
|
157
|
+
HELP_HEALTH = """\
|
|
158
|
+
Usage: dotscope health [options]
|
|
159
|
+
|
|
160
|
+
dotscope health All scopes
|
|
161
|
+
dotscope health auth Single scope
|
|
162
|
+
dotscope health --json Machine-readable output
|
|
163
|
+
|
|
164
|
+
Options:
|
|
165
|
+
--json Machine-readable output"""
|
|
166
|
+
|
|
167
|
+
HELP_HOOK = """\
|
|
168
|
+
Usage: dotscope hook <action>
|
|
169
|
+
|
|
170
|
+
dotscope hook install Install pre-commit + post-commit hooks
|
|
171
|
+
dotscope hook claude Install Claude Code pre-commit enforcement
|
|
172
|
+
dotscope hook uninstall Remove all dotscope hooks
|
|
173
|
+
dotscope hook status Check what's installed
|
|
174
|
+
|
|
175
|
+
Pre-commit blocks on GUARDs only. NUDGEs and NOTEs pass through.
|
|
176
|
+
Post-commit records observations for the feedback loop."""
|
|
177
|
+
|
|
178
|
+
HELP_COMMANDS = {
|
|
179
|
+
"ingest": HELP_INGEST,
|
|
180
|
+
"resolve": HELP_RESOLVE,
|
|
181
|
+
"check": HELP_CHECK,
|
|
182
|
+
"intent": HELP_INTENT,
|
|
183
|
+
"conventions": HELP_CONVENTIONS,
|
|
184
|
+
"diff": HELP_DIFF,
|
|
185
|
+
"voice": HELP_VOICE,
|
|
186
|
+
"bench": HELP_BENCH,
|
|
187
|
+
"test-compiler": HELP_TEST_COMPILER,
|
|
188
|
+
"debug": HELP_DEBUG,
|
|
189
|
+
"health": HELP_HEALTH,
|
|
190
|
+
"hook": HELP_HOOK,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def print_help(command=None):
|
|
195
|
+
"""Print help for a command, or root help if no command given."""
|
|
196
|
+
if command is None:
|
|
197
|
+
print(HELP_ROOT)
|
|
198
|
+
elif command in HELP_COMMANDS:
|
|
199
|
+
print(HELP_COMMANDS[command])
|
|
200
|
+
else:
|
|
201
|
+
import sys
|
|
202
|
+
print(f"Unknown command: {command}\n")
|
|
203
|
+
print("Run dotscope --help to see available commands.")
|
|
204
|
+
sys.exit(1)
|
dotscope/history.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Backward-compatibility stub. Moved to dotscope.passes.history_miner."""
|
|
2
|
+
from .passes.history_miner import * # noqa: F401,F403
|
|
3
|
+
from .models.history import ( # noqa: F401
|
|
4
|
+
FileChange, CommitInfo, FileHistory,
|
|
5
|
+
ChangeCoupling, ImplicitContract, HistoryAnalysis,
|
|
6
|
+
)
|
dotscope/hooks.py
ADDED