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/paths.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared path helpers — normalize, relative, exists checks."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def normalize(base: str, rel_path: str) -> str:
|
|
9
|
+
"""Join and normalize a base + relative path."""
|
|
10
|
+
return os.path.normpath(os.path.join(base, rel_path))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make_relative(abs_path: str, root: Optional[str]) -> str:
|
|
14
|
+
"""Make a path relative to root, falling back to absolute."""
|
|
15
|
+
if root:
|
|
16
|
+
try:
|
|
17
|
+
return os.path.relpath(abs_path, root)
|
|
18
|
+
except ValueError:
|
|
19
|
+
pass
|
|
20
|
+
return abs_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def path_exists(base: str, rel_path: str) -> bool:
|
|
24
|
+
"""Check if a path (possibly with trailing /) exists."""
|
|
25
|
+
full = normalize(base, rel_path)
|
|
26
|
+
return os.path.exists(full.rstrip("/"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def strip_inline_comment(text: str) -> str:
|
|
30
|
+
"""Strip trailing '# comment' from a path or value string."""
|
|
31
|
+
parts = text.split("#", 1)
|
|
32
|
+
return parts[0].strip()
|
dotscope/progress.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Streaming progress for long-running pipeline steps.
|
|
2
|
+
|
|
3
|
+
Emits terse status lines to stderr as each step begins and completes.
|
|
4
|
+
The developer watches the tool think instead of staring at silence.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProgressEmitter:
|
|
12
|
+
"""Emit streaming progress for pipeline steps."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, quiet: bool = False, stream=None):
|
|
15
|
+
self._quiet = quiet
|
|
16
|
+
self._stream = stream or sys.stderr
|
|
17
|
+
self._start_time = 0.0
|
|
18
|
+
|
|
19
|
+
def start(self, action: str) -> None:
|
|
20
|
+
"""Print action with trailing ... (no newline)."""
|
|
21
|
+
if self._quiet:
|
|
22
|
+
return
|
|
23
|
+
self._stream.write(f"dotscope: {action}...")
|
|
24
|
+
self._stream.flush()
|
|
25
|
+
self._start_time = time.perf_counter()
|
|
26
|
+
|
|
27
|
+
def finish(self, result: str) -> None:
|
|
28
|
+
"""Complete the current line with the result."""
|
|
29
|
+
if self._quiet:
|
|
30
|
+
return
|
|
31
|
+
elapsed = time.perf_counter() - self._start_time
|
|
32
|
+
pad = " " * max(1, 45 - len(result))
|
|
33
|
+
if elapsed > 1.0:
|
|
34
|
+
self._stream.write(f"{pad}{result} ({elapsed:.1f}s)\n")
|
|
35
|
+
else:
|
|
36
|
+
self._stream.write(f"{pad}{result}\n")
|
|
37
|
+
self._stream.flush()
|
|
38
|
+
|
|
39
|
+
def skip(self, action: str, reason: str) -> None:
|
|
40
|
+
"""Show a skipped step."""
|
|
41
|
+
if self._quiet:
|
|
42
|
+
return
|
|
43
|
+
self._stream.write(f"dotscope: {action}... skipped ({reason})\n")
|
|
44
|
+
self._stream.flush()
|
dotscope/regression.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Observation regression suite: freeze successful sessions as test cases.
|
|
2
|
+
|
|
3
|
+
When dotscope's internals change, replay frozen sessions to verify the new
|
|
4
|
+
version resolves the same or better context.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
from .models.state import RegressionCase, ReplayResult # noqa: F401
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def maybe_freeze_session(
|
|
18
|
+
observation: object,
|
|
19
|
+
session: object,
|
|
20
|
+
repo_root: str,
|
|
21
|
+
min_recall: float = 0.8,
|
|
22
|
+
) -> Optional[str]:
|
|
23
|
+
"""Freeze a successful session as a regression test case.
|
|
24
|
+
|
|
25
|
+
Returns the case ID if frozen, None otherwise.
|
|
26
|
+
"""
|
|
27
|
+
recall = getattr(observation, "recall", 0.0)
|
|
28
|
+
if recall < min_recall:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
predicted = getattr(session, "predicted_files", [])
|
|
32
|
+
context_hash = getattr(session, "context_hash", "")
|
|
33
|
+
scope_expr = getattr(session, "scope_expr", "")
|
|
34
|
+
|
|
35
|
+
case = RegressionCase(
|
|
36
|
+
id=f"regression_{uuid4().hex[:8]}",
|
|
37
|
+
scope_expr=scope_expr,
|
|
38
|
+
expected_files=list(predicted),
|
|
39
|
+
expected_context_hash=context_hash,
|
|
40
|
+
actual_recall=recall,
|
|
41
|
+
timestamp=getattr(observation, "timestamp", ""),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
reg_dir = os.path.join(repo_root, ".dotscope", "regressions")
|
|
45
|
+
os.makedirs(reg_dir, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
path = os.path.join(reg_dir, f"{case.id}.json")
|
|
48
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
49
|
+
json.dump(asdict(case), f, indent=2)
|
|
50
|
+
|
|
51
|
+
return case.id
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_regressions(repo_root: str) -> List[RegressionCase]:
|
|
55
|
+
"""Load all frozen regression cases."""
|
|
56
|
+
reg_dir = os.path.join(repo_root, ".dotscope", "regressions")
|
|
57
|
+
if not os.path.isdir(reg_dir):
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
cases = []
|
|
61
|
+
for fname in sorted(os.listdir(reg_dir)):
|
|
62
|
+
if not fname.endswith(".json"):
|
|
63
|
+
continue
|
|
64
|
+
path = os.path.join(reg_dir, fname)
|
|
65
|
+
try:
|
|
66
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
67
|
+
d = json.load(f)
|
|
68
|
+
cases.append(RegressionCase(
|
|
69
|
+
id=d["id"],
|
|
70
|
+
scope_expr=d.get("scope_expr", ""),
|
|
71
|
+
budget=d.get("budget"),
|
|
72
|
+
task=d.get("task"),
|
|
73
|
+
expected_files=d.get("expected_files", []),
|
|
74
|
+
expected_context_hash=d.get("expected_context_hash", ""),
|
|
75
|
+
actual_recall=d.get("actual_recall", 0.0),
|
|
76
|
+
timestamp=d.get("timestamp", ""),
|
|
77
|
+
))
|
|
78
|
+
except (json.JSONDecodeError, KeyError, IOError):
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
return cases
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def replay_regression(
|
|
85
|
+
case: RegressionCase,
|
|
86
|
+
repo_root: str,
|
|
87
|
+
) -> ReplayResult:
|
|
88
|
+
"""Replay a frozen session against current codebase state."""
|
|
89
|
+
from .composer import compose
|
|
90
|
+
from .budget import apply_budget
|
|
91
|
+
|
|
92
|
+
resolved = compose(case.scope_expr, root=repo_root, follow_related=True)
|
|
93
|
+
if case.budget:
|
|
94
|
+
resolved = apply_budget(resolved, case.budget)
|
|
95
|
+
|
|
96
|
+
new_files = set(resolved.files)
|
|
97
|
+
expected = set(case.expected_files)
|
|
98
|
+
|
|
99
|
+
new_hash = hashlib.sha256(resolved.context.encode()).hexdigest()[:16]
|
|
100
|
+
|
|
101
|
+
return ReplayResult(
|
|
102
|
+
case=case,
|
|
103
|
+
new_files=sorted(new_files),
|
|
104
|
+
new_context_hash=new_hash,
|
|
105
|
+
files_added=sorted(new_files - expected),
|
|
106
|
+
files_dropped=sorted(expected - new_files),
|
|
107
|
+
context_changed=(new_hash != case.expected_context_hash),
|
|
108
|
+
is_regression=len(expected - new_files) > 0,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def format_replay_report(results: List[ReplayResult]) -> str:
|
|
113
|
+
"""Format replay results for terminal output."""
|
|
114
|
+
if not results:
|
|
115
|
+
return "No regression cases found. Sessions are auto-frozen after successful observations."
|
|
116
|
+
|
|
117
|
+
lines = [f"dotscope test-compiler: replaying {len(results)} historical sessions\n"]
|
|
118
|
+
passed = 0
|
|
119
|
+
regressions = 0
|
|
120
|
+
|
|
121
|
+
for r in results:
|
|
122
|
+
prefix = f" {r.case.id} {r.case.scope_expr}"
|
|
123
|
+
if r.case.budget:
|
|
124
|
+
prefix += f" (budget {r.case.budget})"
|
|
125
|
+
|
|
126
|
+
if not r.is_regression and not r.files_added:
|
|
127
|
+
lines.append(f"{prefix}")
|
|
128
|
+
lines.append(f" Files: {len(r.new_files)}/{len(r.case.expected_files)} same OK")
|
|
129
|
+
passed += 1
|
|
130
|
+
elif not r.is_regression and r.files_added:
|
|
131
|
+
lines.append(f"{prefix}")
|
|
132
|
+
added = ", ".join(r.files_added[:3])
|
|
133
|
+
lines.append(f" Files: +{len(r.files_added)} added ({added}) OK (improvement)")
|
|
134
|
+
passed += 1
|
|
135
|
+
else:
|
|
136
|
+
lines.append(f"{prefix}")
|
|
137
|
+
dropped = ", ".join(r.files_dropped[:3])
|
|
138
|
+
lines.append(f" REGRESSION: {dropped} no longer resolved")
|
|
139
|
+
regressions += 1
|
|
140
|
+
|
|
141
|
+
if r.context_changed:
|
|
142
|
+
lines.append(f" Context hash: changed")
|
|
143
|
+
|
|
144
|
+
lines.append("")
|
|
145
|
+
|
|
146
|
+
lines.append(f" {passed}/{len(results)} passed, {regressions} regression(s) detected")
|
|
147
|
+
return "\n".join(lines)
|
dotscope/resolver.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Core resolution engine: .scope config → concrete file list.
|
|
2
|
+
|
|
3
|
+
Walks includes, applies excludes, follows related scopes with cycle detection.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
import fnmatch
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional, Set
|
|
11
|
+
|
|
12
|
+
from .constants import SKIP_DIRS
|
|
13
|
+
from .models import ResolvedScope, ScopeConfig
|
|
14
|
+
from .tokens import estimate_file_tokens, estimate_context_tokens
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve(
|
|
18
|
+
config: ScopeConfig,
|
|
19
|
+
follow_related: bool = True,
|
|
20
|
+
max_depth: int = 3,
|
|
21
|
+
root: Optional[str] = None,
|
|
22
|
+
) -> ResolvedScope:
|
|
23
|
+
"""Resolve a ScopeConfig to a concrete file list.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
config: Parsed .scope configuration
|
|
27
|
+
follow_related: Whether to follow related scope references
|
|
28
|
+
max_depth: Maximum depth for related scope traversal
|
|
29
|
+
root: Repository root (for resolving related scope paths)
|
|
30
|
+
"""
|
|
31
|
+
return _resolve_inner(config, follow_related, max_depth, root, set(), 0)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_inner(
|
|
35
|
+
config: ScopeConfig,
|
|
36
|
+
follow_related: bool,
|
|
37
|
+
max_depth: int,
|
|
38
|
+
root: Optional[str],
|
|
39
|
+
_visited: Set[str],
|
|
40
|
+
_depth: int,
|
|
41
|
+
) -> ResolvedScope:
|
|
42
|
+
"""Internal resolver with cycle detection state."""
|
|
43
|
+
|
|
44
|
+
# Cycle detection
|
|
45
|
+
abs_path = os.path.abspath(config.path)
|
|
46
|
+
if abs_path in _visited:
|
|
47
|
+
return ResolvedScope(scope_chain=[abs_path])
|
|
48
|
+
_visited.add(abs_path)
|
|
49
|
+
|
|
50
|
+
scope_dir = config.directory
|
|
51
|
+
if root is None:
|
|
52
|
+
from .discovery import find_repo_root
|
|
53
|
+
root = find_repo_root(scope_dir) or scope_dir
|
|
54
|
+
|
|
55
|
+
files = _collect_includes(config.includes, root)
|
|
56
|
+
files = _apply_excludes(files, config.excludes, root)
|
|
57
|
+
context = config.context_str
|
|
58
|
+
file_tokens = sum(estimate_file_tokens(f) for f in files)
|
|
59
|
+
context_tokens = estimate_context_tokens(context)
|
|
60
|
+
|
|
61
|
+
result = ResolvedScope(
|
|
62
|
+
files=files,
|
|
63
|
+
context=context,
|
|
64
|
+
token_estimate=file_tokens + context_tokens,
|
|
65
|
+
scope_chain=[abs_path],
|
|
66
|
+
truncated=False,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if follow_related and config.related and _depth < max_depth:
|
|
70
|
+
for related_path in config.related:
|
|
71
|
+
related_config = _load_related(related_path, scope_dir, root)
|
|
72
|
+
if related_config is None:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
related_resolved = _resolve_inner(
|
|
76
|
+
related_config,
|
|
77
|
+
follow_related=True,
|
|
78
|
+
max_depth=max_depth,
|
|
79
|
+
root=root,
|
|
80
|
+
_visited=_visited,
|
|
81
|
+
_depth=_depth + 1,
|
|
82
|
+
)
|
|
83
|
+
result = result.merge(related_resolved)
|
|
84
|
+
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _collect_includes(includes: List[str], scope_dir: str) -> List[str]:
|
|
89
|
+
"""Expand include paths to concrete file list."""
|
|
90
|
+
files = []
|
|
91
|
+
seen: Set[str] = set()
|
|
92
|
+
|
|
93
|
+
for pattern in includes:
|
|
94
|
+
# Resolve relative to scope directory
|
|
95
|
+
full_path = os.path.normpath(os.path.join(scope_dir, pattern))
|
|
96
|
+
|
|
97
|
+
if pattern.endswith("/"):
|
|
98
|
+
# Directory: recursive walk
|
|
99
|
+
_walk_directory(full_path.rstrip("/"), files, seen)
|
|
100
|
+
elif any(c in pattern for c in "*?["):
|
|
101
|
+
# Glob pattern
|
|
102
|
+
_glob_pattern(pattern, scope_dir, files, seen)
|
|
103
|
+
elif os.path.isfile(full_path):
|
|
104
|
+
# Exact file
|
|
105
|
+
if full_path not in seen:
|
|
106
|
+
files.append(full_path)
|
|
107
|
+
seen.add(full_path)
|
|
108
|
+
elif os.path.isdir(full_path):
|
|
109
|
+
# Directory without trailing slash — still treat as recursive
|
|
110
|
+
_walk_directory(full_path, files, seen)
|
|
111
|
+
|
|
112
|
+
return files
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _walk_directory(dir_path: str, files: List[str], seen: Set[str]) -> None:
|
|
116
|
+
"""Recursively collect all files in a directory."""
|
|
117
|
+
skip_dirs = SKIP_DIRS
|
|
118
|
+
|
|
119
|
+
if not os.path.isdir(dir_path):
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
for dirpath, dirnames, filenames in os.walk(dir_path):
|
|
123
|
+
dirnames[:] = [d for d in dirnames if d not in skip_dirs]
|
|
124
|
+
for filename in sorted(filenames):
|
|
125
|
+
full = os.path.join(dirpath, filename)
|
|
126
|
+
if full not in seen:
|
|
127
|
+
files.append(full)
|
|
128
|
+
seen.add(full)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _glob_pattern(pattern: str, scope_dir: str, files: List[str], seen: Set[str]) -> None:
|
|
132
|
+
"""Expand a glob pattern relative to scope directory."""
|
|
133
|
+
base = Path(scope_dir)
|
|
134
|
+
for match in sorted(base.glob(pattern)):
|
|
135
|
+
if match.is_file():
|
|
136
|
+
full = str(match)
|
|
137
|
+
if full not in seen:
|
|
138
|
+
files.append(full)
|
|
139
|
+
seen.add(full)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _apply_excludes(files: List[str], excludes: List[str], scope_dir: str) -> List[str]:
|
|
143
|
+
"""Filter out files matching exclude patterns."""
|
|
144
|
+
if not excludes:
|
|
145
|
+
return files
|
|
146
|
+
|
|
147
|
+
result = []
|
|
148
|
+
for f in files:
|
|
149
|
+
rel_path = os.path.relpath(f, scope_dir)
|
|
150
|
+
excluded = False
|
|
151
|
+
|
|
152
|
+
for pattern in excludes:
|
|
153
|
+
# Check against relative path
|
|
154
|
+
if fnmatch.fnmatch(rel_path, pattern):
|
|
155
|
+
excluded = True
|
|
156
|
+
break
|
|
157
|
+
# Check against filename only
|
|
158
|
+
if fnmatch.fnmatch(os.path.basename(f), pattern):
|
|
159
|
+
excluded = True
|
|
160
|
+
break
|
|
161
|
+
# Check if file is under an excluded directory
|
|
162
|
+
if pattern.endswith("/"):
|
|
163
|
+
dir_prefix = pattern.rstrip("/")
|
|
164
|
+
if rel_path.startswith(dir_prefix + "/") or rel_path.startswith(dir_prefix + os.sep):
|
|
165
|
+
excluded = True
|
|
166
|
+
break
|
|
167
|
+
# Also match with ** prefix for nested patterns
|
|
168
|
+
if "/" in pattern and fnmatch.fnmatch(rel_path, "**/" + pattern):
|
|
169
|
+
excluded = True
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
if not excluded:
|
|
173
|
+
result.append(f)
|
|
174
|
+
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _load_related(
|
|
179
|
+
related_path: str, scope_dir: str, root: str
|
|
180
|
+
) -> Optional[ScopeConfig]:
|
|
181
|
+
"""Load a related scope file."""
|
|
182
|
+
from .parser import parse_scope_file
|
|
183
|
+
|
|
184
|
+
from .paths import strip_inline_comment
|
|
185
|
+
related_path = strip_inline_comment(related_path)
|
|
186
|
+
|
|
187
|
+
# Try relative to scope directory first
|
|
188
|
+
candidate = os.path.normpath(os.path.join(scope_dir, related_path))
|
|
189
|
+
if os.path.isfile(candidate):
|
|
190
|
+
try:
|
|
191
|
+
return parse_scope_file(candidate)
|
|
192
|
+
except (ValueError, IOError):
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
# Try relative to root
|
|
196
|
+
candidate = os.path.normpath(os.path.join(root, related_path))
|
|
197
|
+
if os.path.isfile(candidate):
|
|
198
|
+
try:
|
|
199
|
+
return parse_scope_file(candidate)
|
|
200
|
+
except (ValueError, IOError):
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
return None
|
dotscope/scanner.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Auto-generate .scope from directory analysis.
|
|
2
|
+
|
|
3
|
+
Scans directory structure, detects language, finds cross-directory imports,
|
|
4
|
+
and produces a reasonable starting .scope configuration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from typing import List, Optional, Set, Tuple
|
|
12
|
+
|
|
13
|
+
from .constants import LANG_MAP, SKIP_DIRS
|
|
14
|
+
from .context import parse_context
|
|
15
|
+
from .models import ScopeConfig
|
|
16
|
+
from .tokens import estimate_file_tokens
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Directories to always exclude
|
|
20
|
+
_DEFAULT_EXCLUDES = [
|
|
21
|
+
"__pycache__/",
|
|
22
|
+
"node_modules/",
|
|
23
|
+
".git/",
|
|
24
|
+
"*.pyc",
|
|
25
|
+
"dist/",
|
|
26
|
+
"build/",
|
|
27
|
+
"*.egg-info/",
|
|
28
|
+
".tox/",
|
|
29
|
+
".mypy_cache/",
|
|
30
|
+
".ruff_cache/",
|
|
31
|
+
"venv/",
|
|
32
|
+
".venv/",
|
|
33
|
+
"*.min.js",
|
|
34
|
+
"*.generated.*",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Directories that are typically test/fixture/migration content
|
|
38
|
+
_TEST_DIRS = {"tests", "test", "__tests__", "spec", "specs"}
|
|
39
|
+
_FIXTURE_DIRS = {"fixtures", "fixture", "testdata", "test_data", "mocks"}
|
|
40
|
+
_MIGRATION_DIRS = {"migrations", "migrate", "alembic"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def scan_directory(path: str) -> ScopeConfig:
|
|
44
|
+
"""Analyze a directory and generate a starter .scope configuration.
|
|
45
|
+
|
|
46
|
+
The human then edits the context field — that's the part that can't be automated.
|
|
47
|
+
"""
|
|
48
|
+
path = os.path.abspath(path)
|
|
49
|
+
dir_name = os.path.basename(path)
|
|
50
|
+
|
|
51
|
+
# Collect file info
|
|
52
|
+
files, lang_counts, total_tokens = _scan_files(path)
|
|
53
|
+
|
|
54
|
+
# Detect primary language
|
|
55
|
+
primary_lang = _detect_language(lang_counts)
|
|
56
|
+
|
|
57
|
+
includes = [f"{dir_name}/"]
|
|
58
|
+
|
|
59
|
+
# Build excludes
|
|
60
|
+
excludes = _build_excludes(path)
|
|
61
|
+
|
|
62
|
+
# Detect cross-directory imports
|
|
63
|
+
external_deps = _find_external_deps(path, files, primary_lang)
|
|
64
|
+
|
|
65
|
+
# Add external dependencies to includes
|
|
66
|
+
for dep in external_deps:
|
|
67
|
+
if dep not in includes:
|
|
68
|
+
includes.append(dep)
|
|
69
|
+
|
|
70
|
+
# Build description
|
|
71
|
+
file_count = len(files)
|
|
72
|
+
description = f"{dir_name} -- {primary_lang or 'mixed'} ({file_count} files)"
|
|
73
|
+
|
|
74
|
+
# Build tags
|
|
75
|
+
tags = _infer_tags(path, dir_name, files)
|
|
76
|
+
|
|
77
|
+
context = parse_context(
|
|
78
|
+
"# TODO: Add architectural context here.\n"
|
|
79
|
+
"# What invariants does this module maintain?\n"
|
|
80
|
+
"# What gotchas should an agent know about?\n"
|
|
81
|
+
"# What conventions does it follow?"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return ScopeConfig(
|
|
85
|
+
path=os.path.join(path, ".scope"),
|
|
86
|
+
description=description,
|
|
87
|
+
includes=includes,
|
|
88
|
+
excludes=excludes,
|
|
89
|
+
context=context,
|
|
90
|
+
related=[],
|
|
91
|
+
owners=[],
|
|
92
|
+
tags=tags,
|
|
93
|
+
tokens_estimate=total_tokens,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _scan_files(path: str) -> Tuple[List[str], Counter, int]:
|
|
98
|
+
"""Walk directory, collect files, count languages, estimate tokens."""
|
|
99
|
+
files = []
|
|
100
|
+
lang_counts: Counter = Counter()
|
|
101
|
+
total_tokens = 0
|
|
102
|
+
|
|
103
|
+
for dirpath, dirnames, filenames in os.walk(path):
|
|
104
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
105
|
+
|
|
106
|
+
for filename in filenames:
|
|
107
|
+
full = os.path.join(dirpath, filename)
|
|
108
|
+
files.append(full)
|
|
109
|
+
|
|
110
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
111
|
+
if ext in LANG_MAP:
|
|
112
|
+
lang_counts[LANG_MAP[ext]] += 1
|
|
113
|
+
|
|
114
|
+
total_tokens += estimate_file_tokens(full)
|
|
115
|
+
|
|
116
|
+
return files, lang_counts, total_tokens
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _detect_language(lang_counts: Counter) -> Optional[str]:
|
|
120
|
+
"""Detect the primary language from extension counts."""
|
|
121
|
+
if not lang_counts:
|
|
122
|
+
return None
|
|
123
|
+
return lang_counts.most_common(1)[0][0]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _build_excludes(path: str) -> List[str]:
|
|
127
|
+
"""Build excludes from detected directories."""
|
|
128
|
+
excludes = []
|
|
129
|
+
dir_name = os.path.basename(path)
|
|
130
|
+
|
|
131
|
+
for entry in os.listdir(path):
|
|
132
|
+
full = os.path.join(path, entry)
|
|
133
|
+
if not os.path.isdir(full):
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
entry_lower = entry.lower()
|
|
137
|
+
if entry_lower in _TEST_DIRS:
|
|
138
|
+
excludes.append(f"{dir_name}/{entry}/fixtures/")
|
|
139
|
+
if entry_lower in _FIXTURE_DIRS:
|
|
140
|
+
excludes.append(f"{dir_name}/{entry}/")
|
|
141
|
+
if entry_lower in _MIGRATION_DIRS:
|
|
142
|
+
excludes.append(f"{dir_name}/{entry}/")
|
|
143
|
+
if entry in SKIP_DIRS:
|
|
144
|
+
excludes.append(f"{dir_name}/{entry}/")
|
|
145
|
+
|
|
146
|
+
# Add common glob excludes
|
|
147
|
+
excludes.extend([
|
|
148
|
+
"*.pyc",
|
|
149
|
+
f"{dir_name}/__pycache__/",
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
return list(dict.fromkeys(excludes)) # dedupe preserving order
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _find_external_deps(
|
|
156
|
+
path: str, files: List[str], lang: Optional[str]
|
|
157
|
+
) -> List[str]:
|
|
158
|
+
"""Parse imports to find cross-directory dependencies."""
|
|
159
|
+
if lang == "Python":
|
|
160
|
+
return _find_python_imports(path, files)
|
|
161
|
+
elif lang in ("JavaScript", "TypeScript"):
|
|
162
|
+
return _find_js_imports(path, files)
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _find_python_imports(path: str, files: List[str]) -> List[str]:
|
|
167
|
+
"""Find Python imports that reference outside the scanned directory."""
|
|
168
|
+
external: Set[str] = set()
|
|
169
|
+
parent = os.path.dirname(path)
|
|
170
|
+
|
|
171
|
+
for f in files:
|
|
172
|
+
if not f.endswith(".py"):
|
|
173
|
+
continue
|
|
174
|
+
try:
|
|
175
|
+
with open(f, "r", encoding="utf-8", errors="replace") as fh:
|
|
176
|
+
for line in fh:
|
|
177
|
+
line = line.strip()
|
|
178
|
+
# from foo.bar import baz
|
|
179
|
+
m = re.match(r"from\s+([\w.]+)\s+import", line)
|
|
180
|
+
if m:
|
|
181
|
+
module = m.group(1).split(".")[0]
|
|
182
|
+
candidate = os.path.join(parent, module)
|
|
183
|
+
if os.path.isdir(candidate) and candidate != path:
|
|
184
|
+
rel = os.path.relpath(candidate, parent)
|
|
185
|
+
external.add(f"{rel}/")
|
|
186
|
+
candidate_file = os.path.join(parent, module + ".py")
|
|
187
|
+
if os.path.isfile(candidate_file) and os.path.dirname(candidate_file) != path:
|
|
188
|
+
rel = os.path.relpath(candidate_file, parent)
|
|
189
|
+
external.add(rel)
|
|
190
|
+
except (IOError, OSError):
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
return sorted(external)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _find_js_imports(path: str, files: List[str]) -> List[str]:
|
|
197
|
+
"""Find JS/TS imports that reference outside the scanned directory."""
|
|
198
|
+
external: Set[str] = set()
|
|
199
|
+
parent = os.path.dirname(path)
|
|
200
|
+
|
|
201
|
+
for f in files:
|
|
202
|
+
if not any(f.endswith(ext) for ext in (".js", ".ts", ".jsx", ".tsx")):
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
with open(f, "r", encoding="utf-8", errors="replace") as fh:
|
|
206
|
+
for line in fh:
|
|
207
|
+
# import ... from '../foo/bar'
|
|
208
|
+
# require('../foo/bar')
|
|
209
|
+
for m in re.finditer(r"""(?:from|require\()\s*['"](\.\./[^'"]+)['"]""", line):
|
|
210
|
+
rel_import = m.group(1)
|
|
211
|
+
abs_import = os.path.normpath(os.path.join(os.path.dirname(f), rel_import))
|
|
212
|
+
if not abs_import.startswith(path):
|
|
213
|
+
rel = os.path.relpath(abs_import, parent)
|
|
214
|
+
if os.path.isdir(abs_import):
|
|
215
|
+
external.add(f"{rel}/")
|
|
216
|
+
else:
|
|
217
|
+
external.add(rel)
|
|
218
|
+
except (IOError, OSError):
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
return sorted(external)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _infer_tags(path: str, dir_name: str, files: List[str]) -> List[str]:
|
|
225
|
+
"""Infer tags from directory name and file contents."""
|
|
226
|
+
tags = [dir_name.lower()]
|
|
227
|
+
|
|
228
|
+
# Infer from common file/directory names
|
|
229
|
+
name_lower = dir_name.lower()
|
|
230
|
+
tag_hints = {
|
|
231
|
+
"auth": ["authentication", "security"],
|
|
232
|
+
"api": ["rest", "endpoint"],
|
|
233
|
+
"payment": ["billing", "stripe"],
|
|
234
|
+
"user": ["account", "profile"],
|
|
235
|
+
"admin": ["dashboard", "management"],
|
|
236
|
+
"config": ["configuration", "settings"],
|
|
237
|
+
"deploy": ["infrastructure", "ci-cd"],
|
|
238
|
+
"test": ["testing"],
|
|
239
|
+
"model": ["database", "orm"],
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for hint, extra_tags in tag_hints.items():
|
|
243
|
+
if hint in name_lower:
|
|
244
|
+
tags.extend(extra_tags)
|
|
245
|
+
|
|
246
|
+
return list(dict.fromkeys(tags))
|
dotscope/sessions.py
ADDED