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
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
@@ -0,0 +1,2 @@
1
+ """Backward-compatibility stub. Moved to dotscope.storage.session_manager."""
2
+ from .storage.session_manager import * # noqa: F401,F403