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/cli.py
ADDED
|
@@ -0,0 +1,1447 @@
|
|
|
1
|
+
"""CLI entry point for dotscope."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _safe_print(text, **kwargs):
|
|
10
|
+
"""Print with ASCII fallback for Windows cp1252 terminals."""
|
|
11
|
+
try:
|
|
12
|
+
print(text, **kwargs)
|
|
13
|
+
except UnicodeEncodeError:
|
|
14
|
+
print(text.encode("ascii", errors="replace").decode("ascii"), **kwargs)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv=None):
|
|
18
|
+
# Intercept help before argparse touches it
|
|
19
|
+
args_list = argv if argv is not None else sys.argv[1:]
|
|
20
|
+
if not args_list or args_list == ["help"] or args_list == ["--help"] or args_list == ["-h"]:
|
|
21
|
+
from .help import print_help
|
|
22
|
+
print_help()
|
|
23
|
+
return
|
|
24
|
+
if len(args_list) >= 2 and args_list[1] in ("--help", "-h"):
|
|
25
|
+
from .help import print_help, HELP_COMMANDS
|
|
26
|
+
cmd = args_list[0]
|
|
27
|
+
if cmd in HELP_COMMANDS:
|
|
28
|
+
print_help(cmd)
|
|
29
|
+
return
|
|
30
|
+
# Fall through to argparse for unknown commands
|
|
31
|
+
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="dotscope",
|
|
34
|
+
description="Directory-scoped context boundaries for AI coding agents",
|
|
35
|
+
add_help=False,
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {_version()}")
|
|
38
|
+
parser.add_argument("-h", "--help", action="store_true", dest="show_help")
|
|
39
|
+
|
|
40
|
+
sub = parser.add_subparsers(dest="command")
|
|
41
|
+
|
|
42
|
+
# --- resolve ---
|
|
43
|
+
p_resolve = sub.add_parser("resolve", help="Resolve a scope expression to files")
|
|
44
|
+
p_resolve.add_argument("scope", help="Scope name, path, or expression (e.g., auth+payments)")
|
|
45
|
+
p_resolve.add_argument("--budget", type=int, default=None, help="Max tokens (context + files)")
|
|
46
|
+
p_resolve.add_argument("--tokens", action="store_true", help="Show per-file token counts")
|
|
47
|
+
p_resolve.add_argument("--json", action="store_true", help="Output as JSON")
|
|
48
|
+
p_resolve.add_argument("--cursor", action="store_true", help="Output as .cursorrules format")
|
|
49
|
+
p_resolve.add_argument("--no-related", action="store_true", help="Don't follow related scopes")
|
|
50
|
+
p_resolve.add_argument("--task", default=None, help="Task description for relevance ranking")
|
|
51
|
+
|
|
52
|
+
# --- context ---
|
|
53
|
+
p_context = sub.add_parser("context", help="Print context for a scope")
|
|
54
|
+
p_context.add_argument("scope", help="Scope name or path")
|
|
55
|
+
p_context.add_argument("--section", default=None, help="Filter to a named section")
|
|
56
|
+
|
|
57
|
+
# --- match ---
|
|
58
|
+
p_match = sub.add_parser("match", help="Match a task description to scope(s)")
|
|
59
|
+
p_match.add_argument("task", help="Task description string")
|
|
60
|
+
|
|
61
|
+
# --- init ---
|
|
62
|
+
p_init = sub.add_parser("init", help="One command: ingest, install hooks, configure agents")
|
|
63
|
+
p_init.add_argument("path", nargs="?", default=".", help="Repository root")
|
|
64
|
+
p_init.add_argument("--quiet", action="store_true", help="Suppress progress (for CI)")
|
|
65
|
+
|
|
66
|
+
# --- validate ---
|
|
67
|
+
sub.add_parser("validate", help="Check all .scope files for broken paths")
|
|
68
|
+
|
|
69
|
+
# --- stats ---
|
|
70
|
+
sub.add_parser("stats", help="Token savings report across all scopes")
|
|
71
|
+
|
|
72
|
+
# --- tree ---
|
|
73
|
+
sub.add_parser("tree", help="Visual tree of all scopes and relationships")
|
|
74
|
+
|
|
75
|
+
# --- health ---
|
|
76
|
+
sub.add_parser("health", help="Scope health: staleness, coverage, drift")
|
|
77
|
+
|
|
78
|
+
# --- ingest ---
|
|
79
|
+
p_ingest = sub.add_parser("ingest", help="Reverse-engineer .scope files from an existing codebase")
|
|
80
|
+
p_ingest.add_argument("--dir", default=".", help="Repository root to ingest")
|
|
81
|
+
p_ingest.add_argument("--no-history", action="store_true", help="Skip git history mining")
|
|
82
|
+
p_ingest.add_argument("--no-docs", action="store_true", help="Skip doc absorption")
|
|
83
|
+
p_ingest.add_argument("--dry-run", action="store_true", help="Plan only, don't write files")
|
|
84
|
+
p_ingest.add_argument("--max-commits", type=int, default=500, help="Max git commits to analyze")
|
|
85
|
+
p_ingest.add_argument("--quiet", action="store_true", help="Suppress progress output (for CI)")
|
|
86
|
+
p_ingest.add_argument("--voice", choices=["prescriptive", "adaptive"], default=None,
|
|
87
|
+
help="Override voice mode (prescriptive for new, adaptive for existing)")
|
|
88
|
+
|
|
89
|
+
# --- voice ---
|
|
90
|
+
p_voice = sub.add_parser("voice", help="View and manage code voice")
|
|
91
|
+
p_voice.add_argument("--upgrade", metavar="RULE", help="Upgrade enforcement for a rule (typing, bare_excepts)")
|
|
92
|
+
p_voice.add_argument("--reset", action="store_true", help="Reset voice to defaults")
|
|
93
|
+
p_voice.add_argument("--json", action="store_true", help="Machine-readable output")
|
|
94
|
+
|
|
95
|
+
# --- impact ---
|
|
96
|
+
p_impact = sub.add_parser("impact", help="Predict blast radius of changes to a file")
|
|
97
|
+
p_impact.add_argument("file", help="File path to analyze impact for")
|
|
98
|
+
|
|
99
|
+
# --- backtest ---
|
|
100
|
+
p_backtest = sub.add_parser("backtest", help="Validate scopes against git history")
|
|
101
|
+
p_backtest.add_argument("--commits", type=int, default=50, help="Number of commits to test against")
|
|
102
|
+
|
|
103
|
+
# --- observe ---
|
|
104
|
+
p_observe = sub.add_parser("observe", help="Record observation for a commit (called by post-commit hook)")
|
|
105
|
+
p_observe.add_argument("commit", help="Commit hash to observe")
|
|
106
|
+
|
|
107
|
+
# --- incremental ---
|
|
108
|
+
p_incremental = sub.add_parser("incremental", help="Incremental scope update (called by post-commit hook)")
|
|
109
|
+
p_incremental.add_argument("commit", help="Commit hash")
|
|
110
|
+
|
|
111
|
+
# --- hook ---
|
|
112
|
+
p_hook = sub.add_parser("hook", help="Manage git hooks")
|
|
113
|
+
hook_sub = p_hook.add_subparsers(dest="hook_action")
|
|
114
|
+
hook_sub.add_parser("install", help="Install post-commit observer hook")
|
|
115
|
+
hook_sub.add_parser("uninstall", help="Remove post-commit observer hook")
|
|
116
|
+
hook_sub.add_parser("status", help="Check if hook is installed")
|
|
117
|
+
hook_sub.add_parser("claude", help="Install Claude Code pre-commit enforcement")
|
|
118
|
+
|
|
119
|
+
# --- utility ---
|
|
120
|
+
p_utility = sub.add_parser("utility", help="Show utility scores for a scope")
|
|
121
|
+
p_utility.add_argument("scope", help="Scope name")
|
|
122
|
+
|
|
123
|
+
# --- virtual ---
|
|
124
|
+
sub.add_parser("virtual", help="Detect and show virtual (cross-cutting) scopes")
|
|
125
|
+
|
|
126
|
+
# --- lessons ---
|
|
127
|
+
p_lessons = sub.add_parser("lessons", help="Show lessons for a scope")
|
|
128
|
+
p_lessons.add_argument("scope", help="Scope name")
|
|
129
|
+
|
|
130
|
+
# --- invariants ---
|
|
131
|
+
p_invariants = sub.add_parser("invariants", help="Show observed invariants for a scope")
|
|
132
|
+
p_invariants.add_argument("scope", help="Scope name")
|
|
133
|
+
|
|
134
|
+
# --- rebuild ---
|
|
135
|
+
sub.add_parser("rebuild", help="Rebuild derived state from event logs")
|
|
136
|
+
|
|
137
|
+
# --- check ---
|
|
138
|
+
p_check = sub.add_parser("check", help="Validate a diff against codebase rules")
|
|
139
|
+
p_check.add_argument("--diff", default=None, help="Path to diff file (default: staged changes)")
|
|
140
|
+
p_check.add_argument("--session", default=None, help="Session ID for boundary checking")
|
|
141
|
+
p_check.add_argument("--acknowledge", action="append", default=[], help="Acknowledge a hold by ID")
|
|
142
|
+
p_check.add_argument("--backtest", action="store_true", help="Replay recent commits against checks")
|
|
143
|
+
p_check.add_argument("--commits", type=int, default=10, help="Commits to replay in backtest mode")
|
|
144
|
+
p_check.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON")
|
|
145
|
+
|
|
146
|
+
# --- intent ---
|
|
147
|
+
p_intent = sub.add_parser("intent", help="Manage architectural intents")
|
|
148
|
+
intent_sub = p_intent.add_subparsers(dest="intent_action")
|
|
149
|
+
p_intent_add = intent_sub.add_parser("add", help="Add an architectural intent")
|
|
150
|
+
p_intent_add.add_argument("directive", choices=["decouple", "deprecate", "freeze", "consolidate"])
|
|
151
|
+
p_intent_add.add_argument("targets", nargs="+", help="Modules or files")
|
|
152
|
+
p_intent_add.add_argument("--reason", default="", help="Why this intent exists")
|
|
153
|
+
p_intent_add.add_argument("--replacement", default=None, help="Replacement (for deprecate)")
|
|
154
|
+
p_intent_add.add_argument("--target", default=None, help="Consolidation target")
|
|
155
|
+
p_intent_list = intent_sub.add_parser("list", help="List all intents")
|
|
156
|
+
p_intent_rm = intent_sub.add_parser("remove", help="Remove an intent by ID")
|
|
157
|
+
p_intent_rm.add_argument("id", help="Intent ID to remove")
|
|
158
|
+
|
|
159
|
+
# --- conventions ---
|
|
160
|
+
p_conv = sub.add_parser("conventions", help="List or discover conventions")
|
|
161
|
+
p_conv.add_argument("--discover", action="store_true", help="Discover conventions from codebase")
|
|
162
|
+
p_conv.add_argument("--accept", action="store_true", help="Accept discovered conventions")
|
|
163
|
+
p_conv.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON")
|
|
164
|
+
|
|
165
|
+
# --- diff ---
|
|
166
|
+
p_diff = sub.add_parser("diff", help="Semantic diff against conventions")
|
|
167
|
+
p_diff.add_argument("ref", nargs="?", default=None, help="Git ref to diff against")
|
|
168
|
+
p_diff.add_argument("--staged", action="store_true", help="Diff staged changes")
|
|
169
|
+
p_diff.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON")
|
|
170
|
+
|
|
171
|
+
# --- test-compiler ---
|
|
172
|
+
p_tc = sub.add_parser("test-compiler", help="Replay frozen sessions as regression tests")
|
|
173
|
+
p_tc.add_argument("--scope", default=None, help="Filter to a specific scope")
|
|
174
|
+
|
|
175
|
+
# --- bench ---
|
|
176
|
+
p_bench = sub.add_parser("bench", help="Performance and accuracy benchmarks")
|
|
177
|
+
p_bench.add_argument("--json", dest="json_output", action="store_true", help="JSON output")
|
|
178
|
+
|
|
179
|
+
# --- debug ---
|
|
180
|
+
p_debug = sub.add_parser("debug", help="Bisect a bad session to find root cause")
|
|
181
|
+
p_debug.add_argument("session_id", nargs="?", default=None, help="Session ID to debug")
|
|
182
|
+
p_debug.add_argument("--last", action="store_true", help="Debug most recent bad session")
|
|
183
|
+
p_debug.add_argument("--list", dest="list_bad", action="store_true", help="List bad sessions")
|
|
184
|
+
|
|
185
|
+
args = parser.parse_args(argv)
|
|
186
|
+
|
|
187
|
+
if args.command is None or getattr(args, "show_help", False):
|
|
188
|
+
from .help import print_help
|
|
189
|
+
print_help()
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
handler = {
|
|
194
|
+
"resolve": _cmd_resolve,
|
|
195
|
+
"context": _cmd_context,
|
|
196
|
+
"match": _cmd_match,
|
|
197
|
+
"init": _cmd_init,
|
|
198
|
+
"validate": _cmd_validate,
|
|
199
|
+
"stats": _cmd_stats,
|
|
200
|
+
"tree": _cmd_tree,
|
|
201
|
+
"health": _cmd_health,
|
|
202
|
+
"ingest": _cmd_ingest,
|
|
203
|
+
"impact": _cmd_impact,
|
|
204
|
+
"backtest": _cmd_backtest,
|
|
205
|
+
"observe": _cmd_observe,
|
|
206
|
+
"incremental": _cmd_incremental,
|
|
207
|
+
"hook": _cmd_hook,
|
|
208
|
+
"utility": _cmd_utility,
|
|
209
|
+
"virtual": _cmd_virtual,
|
|
210
|
+
"lessons": _cmd_lessons,
|
|
211
|
+
"invariants": _cmd_invariants,
|
|
212
|
+
"rebuild": _cmd_rebuild,
|
|
213
|
+
"check": _cmd_check,
|
|
214
|
+
"intent": _cmd_intent,
|
|
215
|
+
"conventions": _cmd_conventions,
|
|
216
|
+
"diff": _cmd_diff,
|
|
217
|
+
"voice": _cmd_voice,
|
|
218
|
+
"test-compiler": _cmd_test_compiler,
|
|
219
|
+
"bench": _cmd_bench,
|
|
220
|
+
"debug": _cmd_debug,
|
|
221
|
+
}[args.command]
|
|
222
|
+
handler(args)
|
|
223
|
+
except (ValueError, FileNotFoundError) as e:
|
|
224
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
225
|
+
sys.exit(1)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Command handlers
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _cmd_resolve(args):
|
|
233
|
+
from .composer import compose
|
|
234
|
+
from .passes.budget_allocator import apply_budget
|
|
235
|
+
from .discovery import find_repo_root
|
|
236
|
+
from .formatter import format_resolved
|
|
237
|
+
|
|
238
|
+
root = find_repo_root()
|
|
239
|
+
follow_related = not args.no_related
|
|
240
|
+
|
|
241
|
+
resolved = compose(args.scope, root=root, follow_related=follow_related)
|
|
242
|
+
|
|
243
|
+
if args.budget:
|
|
244
|
+
resolved = apply_budget(resolved, args.budget, task=args.task)
|
|
245
|
+
|
|
246
|
+
fmt = "json" if args.json else ("cursor" if args.cursor else "plain")
|
|
247
|
+
print(format_resolved(resolved, fmt=fmt, root=root, show_tokens=args.tokens))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _cmd_context(args):
|
|
251
|
+
from .discovery import find_scope, find_repo_root
|
|
252
|
+
from .context import query_context
|
|
253
|
+
|
|
254
|
+
root = find_repo_root()
|
|
255
|
+
config = find_scope(args.scope, root)
|
|
256
|
+
if config is None:
|
|
257
|
+
raise ValueError(f"Scope not found: {args.scope}")
|
|
258
|
+
|
|
259
|
+
result = query_context(config.context, args.section)
|
|
260
|
+
if result:
|
|
261
|
+
print(result)
|
|
262
|
+
else:
|
|
263
|
+
section_msg = f" (section: {args.section})" if args.section else ""
|
|
264
|
+
print(f"No context found for scope '{args.scope}'{section_msg}", file=sys.stderr)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _cmd_match(args):
|
|
268
|
+
from .matcher import match_task
|
|
269
|
+
from .discovery import find_repo_root, load_index, find_all_scopes
|
|
270
|
+
from .parser import parse_scope_file
|
|
271
|
+
|
|
272
|
+
root = find_repo_root()
|
|
273
|
+
if root is None:
|
|
274
|
+
raise ValueError("Could not find repository root")
|
|
275
|
+
|
|
276
|
+
index = load_index(root)
|
|
277
|
+
scope_files = find_all_scopes(root)
|
|
278
|
+
|
|
279
|
+
# Build scope list for matching
|
|
280
|
+
scopes = []
|
|
281
|
+
if index:
|
|
282
|
+
for name, entry in index.scopes.items():
|
|
283
|
+
scopes.append((name, entry.keywords, entry.description or ""))
|
|
284
|
+
else:
|
|
285
|
+
for sf in scope_files:
|
|
286
|
+
try:
|
|
287
|
+
config = parse_scope_file(sf)
|
|
288
|
+
name = os.path.relpath(os.path.dirname(sf), root)
|
|
289
|
+
scopes.append((name, config.tags, config.description))
|
|
290
|
+
except (ValueError, IOError):
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
matches = match_task(args.task, scopes)
|
|
294
|
+
|
|
295
|
+
if not matches:
|
|
296
|
+
print("No matching scopes found.", file=sys.stderr)
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
for name, score in matches:
|
|
300
|
+
print(f"Matched: {name} (confidence: {score:.2f})")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _cmd_init(args):
|
|
304
|
+
"""One command: ingest, hooks, MCP config, counterfactual demo."""
|
|
305
|
+
root = os.path.abspath(getattr(args, "path", None) or ".")
|
|
306
|
+
quiet = getattr(args, "quiet", False)
|
|
307
|
+
|
|
308
|
+
# 1. Ingest
|
|
309
|
+
from .ingest import ingest
|
|
310
|
+
result = ingest(root, quiet=quiet)
|
|
311
|
+
|
|
312
|
+
# 2. Install hooks
|
|
313
|
+
try:
|
|
314
|
+
from .storage.git_hooks import install_hook
|
|
315
|
+
install_hook(root)
|
|
316
|
+
if not quiet:
|
|
317
|
+
print("dotscope: hooks installed", file=sys.stderr)
|
|
318
|
+
except Exception as e:
|
|
319
|
+
print(f"dotscope: hook install failed: {e}", file=sys.stderr)
|
|
320
|
+
|
|
321
|
+
# 3. Auto-configure MCP for detected IDEs
|
|
322
|
+
try:
|
|
323
|
+
from .storage.mcp_config import configure_mcp
|
|
324
|
+
configured = configure_mcp(root)
|
|
325
|
+
if configured and not quiet:
|
|
326
|
+
print(f"dotscope: MCP configured for {', '.join(configured)}", file=sys.stderr)
|
|
327
|
+
except Exception as e:
|
|
328
|
+
print(f"dotscope: MCP config failed: {e}", file=sys.stderr)
|
|
329
|
+
|
|
330
|
+
# 4. Backtest as counterfactual demo
|
|
331
|
+
if not quiet:
|
|
332
|
+
try:
|
|
333
|
+
from .passes.backtest import backtest_scopes
|
|
334
|
+
report = backtest_scopes(root, commits=50)
|
|
335
|
+
if report and report.get("results"):
|
|
336
|
+
total_violations = 0
|
|
337
|
+
for scope_result in report["results"]:
|
|
338
|
+
total_violations += scope_result.get("missed_files", 0)
|
|
339
|
+
|
|
340
|
+
# Extract stats from IngestPlan object
|
|
341
|
+
stats = {
|
|
342
|
+
"scopes_written": len(result.scopes) if hasattr(result, "scopes") else 0,
|
|
343
|
+
"contracts_found": (
|
|
344
|
+
len(result.history.implicit_contracts)
|
|
345
|
+
if hasattr(result, "history") and result.history else 0
|
|
346
|
+
),
|
|
347
|
+
"conventions_found": (
|
|
348
|
+
len(result.discovered_conventions) if hasattr(result, "discovered_conventions") else 0
|
|
349
|
+
),
|
|
350
|
+
}
|
|
351
|
+
_print_counterfactual(stats, report, total_violations)
|
|
352
|
+
else:
|
|
353
|
+
_print_summary(result)
|
|
354
|
+
except Exception:
|
|
355
|
+
# Backtest may fail on repos with few commits
|
|
356
|
+
_print_summary(result)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _print_counterfactual(ingest_result, backtest, violations):
|
|
360
|
+
"""Format init output as a counterfactual story."""
|
|
361
|
+
scopes = ingest_result.get("scopes_written", 0) if isinstance(ingest_result, dict) else 0
|
|
362
|
+
contracts = ingest_result.get("contracts_found", 0) if isinstance(ingest_result, dict) else 0
|
|
363
|
+
conventions = ingest_result.get("conventions_found", 0) if isinstance(ingest_result, dict) else 0
|
|
364
|
+
|
|
365
|
+
recall = backtest.get("overall_recall", 0)
|
|
366
|
+
|
|
367
|
+
lines = [""]
|
|
368
|
+
lines.append(f" {scopes} scopes, {contracts} contracts, {conventions} conventions, {recall:.0%} recall")
|
|
369
|
+
lines.append("")
|
|
370
|
+
|
|
371
|
+
if violations > 0:
|
|
372
|
+
lines.append(f" What dotscope would have caught in your last 50 commits:")
|
|
373
|
+
lines.append(f" {violations} files that agents would have missed")
|
|
374
|
+
lines.append("")
|
|
375
|
+
lines.append(" Your agents are ready.")
|
|
376
|
+
lines.append("")
|
|
377
|
+
|
|
378
|
+
print("\n".join(lines), file=sys.stderr)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _print_summary(ingest_result):
|
|
382
|
+
"""Fallback when backtest isn't available."""
|
|
383
|
+
print("", file=sys.stderr)
|
|
384
|
+
print(" Your agents are ready.", file=sys.stderr)
|
|
385
|
+
print("", file=sys.stderr)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _cmd_validate(args):
|
|
389
|
+
from .discovery import find_repo_root, find_all_scopes
|
|
390
|
+
from .parser import parse_scope_file
|
|
391
|
+
|
|
392
|
+
root = find_repo_root()
|
|
393
|
+
if root is None:
|
|
394
|
+
raise ValueError("Could not find repository root")
|
|
395
|
+
|
|
396
|
+
scope_files = find_all_scopes(root)
|
|
397
|
+
if not scope_files:
|
|
398
|
+
print("No .scope files found.")
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
errors = 0
|
|
402
|
+
warnings = 0
|
|
403
|
+
|
|
404
|
+
for sf in scope_files:
|
|
405
|
+
rel = os.path.relpath(sf, root)
|
|
406
|
+
try:
|
|
407
|
+
config = parse_scope_file(sf)
|
|
408
|
+
except ValueError as e:
|
|
409
|
+
print(f"ERROR {rel}: {e}")
|
|
410
|
+
errors += 1
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
scope_dir = os.path.dirname(sf)
|
|
414
|
+
|
|
415
|
+
from .paths import path_exists, strip_inline_comment
|
|
416
|
+
|
|
417
|
+
for inc in config.includes:
|
|
418
|
+
if not path_exists(root, inc):
|
|
419
|
+
print(f"ERROR {rel}: include path not found: {inc}")
|
|
420
|
+
errors += 1
|
|
421
|
+
|
|
422
|
+
for related in config.related:
|
|
423
|
+
clean = strip_inline_comment(related)
|
|
424
|
+
if not path_exists(scope_dir, clean) and not path_exists(root, clean):
|
|
425
|
+
print(f"WARN {rel}: related scope not found: {clean}")
|
|
426
|
+
warnings += 1
|
|
427
|
+
|
|
428
|
+
if not config.description.strip():
|
|
429
|
+
print(f"WARN {rel}: empty description")
|
|
430
|
+
warnings += 1
|
|
431
|
+
|
|
432
|
+
if not config.context_str.strip():
|
|
433
|
+
print(f"WARN {rel}: no context (this is the most valuable part)")
|
|
434
|
+
warnings += 1
|
|
435
|
+
|
|
436
|
+
print(f"\nChecked {len(scope_files)} scope(s): {errors} error(s), {warnings} warning(s)")
|
|
437
|
+
if errors > 0:
|
|
438
|
+
sys.exit(1)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _cmd_stats(args):
|
|
442
|
+
from .discovery import find_repo_root, find_all_scopes
|
|
443
|
+
from .parser import parse_scope_file
|
|
444
|
+
from .resolver import resolve
|
|
445
|
+
from .tokens import estimate_file_tokens
|
|
446
|
+
from .formatter import format_stats
|
|
447
|
+
|
|
448
|
+
root = find_repo_root()
|
|
449
|
+
if root is None:
|
|
450
|
+
raise ValueError("Could not find repository root")
|
|
451
|
+
|
|
452
|
+
total_files = 0
|
|
453
|
+
total_tokens = 0
|
|
454
|
+
skip_dirs = {".git", "node_modules", "__pycache__", "venv", ".venv", "dist", "build"}
|
|
455
|
+
|
|
456
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
457
|
+
dirnames[:] = [d for d in dirnames if d not in skip_dirs]
|
|
458
|
+
for f in filenames:
|
|
459
|
+
full = os.path.join(dirpath, f)
|
|
460
|
+
total_files += 1
|
|
461
|
+
total_tokens += estimate_file_tokens(full)
|
|
462
|
+
|
|
463
|
+
# Resolve each scope
|
|
464
|
+
scope_stats = []
|
|
465
|
+
for sf in find_all_scopes(root):
|
|
466
|
+
try:
|
|
467
|
+
config = parse_scope_file(sf)
|
|
468
|
+
resolved = resolve(config, follow_related=False, root=root)
|
|
469
|
+
name = os.path.relpath(os.path.dirname(sf), root) + "/"
|
|
470
|
+
scope_stats.append((name, len(resolved.files), resolved.token_estimate))
|
|
471
|
+
except (ValueError, IOError):
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
print(format_stats(scope_stats, total_files, total_tokens))
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _cmd_tree(args):
|
|
478
|
+
from .discovery import find_repo_root, find_all_scopes
|
|
479
|
+
from .parser import parse_scope_file
|
|
480
|
+
from .formatter import format_tree
|
|
481
|
+
|
|
482
|
+
root = find_repo_root()
|
|
483
|
+
if root is None:
|
|
484
|
+
raise ValueError("Could not find repository root")
|
|
485
|
+
|
|
486
|
+
scopes = []
|
|
487
|
+
for sf in find_all_scopes(root):
|
|
488
|
+
try:
|
|
489
|
+
config = parse_scope_file(sf)
|
|
490
|
+
scopes.append((sf, config))
|
|
491
|
+
except (ValueError, IOError):
|
|
492
|
+
scopes.append((sf, None))
|
|
493
|
+
|
|
494
|
+
print(format_tree(scopes, root))
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _cmd_health(args):
|
|
498
|
+
from .health import full_health_report
|
|
499
|
+
from .discovery import find_repo_root
|
|
500
|
+
|
|
501
|
+
root = find_repo_root()
|
|
502
|
+
if root is None:
|
|
503
|
+
raise ValueError("Could not find repository root")
|
|
504
|
+
|
|
505
|
+
report = full_health_report(root)
|
|
506
|
+
|
|
507
|
+
if not report.issues:
|
|
508
|
+
print(f"All {report.scopes_checked} scope(s) healthy. "
|
|
509
|
+
f"Coverage: {report.coverage_pct:.0f}% "
|
|
510
|
+
f"({report.directories_covered}/{report.directories_total} directories)")
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
for issue in report.issues:
|
|
514
|
+
rel = os.path.relpath(issue.scope_path, root) if issue.scope_path else "repo"
|
|
515
|
+
tag = issue.severity.upper()
|
|
516
|
+
print(f"{tag:5} [{issue.category}] {rel}: {issue.message}")
|
|
517
|
+
|
|
518
|
+
print(f"\n{report.scopes_checked} scope(s) checked, "
|
|
519
|
+
f"{len(report.errors)} error(s), {len(report.warnings)} warning(s)")
|
|
520
|
+
print(f"Coverage: {report.coverage_pct:.0f}% "
|
|
521
|
+
f"({report.directories_covered}/{report.directories_total} directories)")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _cmd_ingest(args):
|
|
525
|
+
from .ingest import ingest, format_ingest_report
|
|
526
|
+
|
|
527
|
+
root = os.path.abspath(args.dir)
|
|
528
|
+
plan = ingest(
|
|
529
|
+
root,
|
|
530
|
+
mine_history=not args.no_history,
|
|
531
|
+
absorb=not args.no_docs,
|
|
532
|
+
max_commits=args.max_commits,
|
|
533
|
+
dry_run=args.dry_run,
|
|
534
|
+
quiet=args.quiet,
|
|
535
|
+
voice_override=getattr(args, "voice", None),
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
report = format_ingest_report(plan)
|
|
539
|
+
try:
|
|
540
|
+
print(report)
|
|
541
|
+
except UnicodeEncodeError:
|
|
542
|
+
# Windows terminals with cp1252 — write as safe ASCII
|
|
543
|
+
print(report.encode("ascii", errors="replace").decode("ascii"))
|
|
544
|
+
|
|
545
|
+
if args.dry_run:
|
|
546
|
+
print("Dry run: no files written. Remove --dry-run to write scope files.")
|
|
547
|
+
else:
|
|
548
|
+
# Onboarding: mark milestone + show next step + vc tip
|
|
549
|
+
try:
|
|
550
|
+
from .storage.onboarding import (
|
|
551
|
+
mark_milestone, next_step, version_control_tip, mark_vc_tip_shown,
|
|
552
|
+
)
|
|
553
|
+
mark_milestone(root, "first_ingest")
|
|
554
|
+
tip = version_control_tip(mark_milestone(root, "first_ingest"))
|
|
555
|
+
if tip:
|
|
556
|
+
print(f"\n{tip}")
|
|
557
|
+
mark_vc_tip_shown(root)
|
|
558
|
+
ns = next_step(mark_milestone(root, "first_ingest"))
|
|
559
|
+
if ns:
|
|
560
|
+
print(f"\n{ns}")
|
|
561
|
+
except Exception:
|
|
562
|
+
pass
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _cmd_impact(args):
|
|
566
|
+
from .passes.graph_builder import build_graph, transitive_dependents
|
|
567
|
+
from .discovery import find_repo_root
|
|
568
|
+
|
|
569
|
+
root = find_repo_root()
|
|
570
|
+
if root is None:
|
|
571
|
+
raise ValueError("Could not find repository root")
|
|
572
|
+
|
|
573
|
+
target = os.path.relpath(os.path.abspath(args.file), root)
|
|
574
|
+
graph = build_graph(root)
|
|
575
|
+
node = graph.files.get(target)
|
|
576
|
+
|
|
577
|
+
print(f"Impact analysis for: {target}")
|
|
578
|
+
print()
|
|
579
|
+
|
|
580
|
+
if node and node.imports:
|
|
581
|
+
print(f"Direct imports ({len(node.imports)}):")
|
|
582
|
+
for imp in node.imports:
|
|
583
|
+
_safe_print(f" -> {imp}")
|
|
584
|
+
|
|
585
|
+
if node and node.imported_by:
|
|
586
|
+
print(f"\nDirect dependents ({len(node.imported_by)}):")
|
|
587
|
+
for imp_by in node.imported_by:
|
|
588
|
+
_safe_print(f" <- {imp_by}")
|
|
589
|
+
|
|
590
|
+
all_dependents = transitive_dependents(graph, target)
|
|
591
|
+
transitive_only = all_dependents - set(node.imported_by if node else [])
|
|
592
|
+
|
|
593
|
+
if transitive_only:
|
|
594
|
+
print(f"\nTransitive dependents ({len(transitive_only)}):")
|
|
595
|
+
for t in sorted(transitive_only):
|
|
596
|
+
_safe_print(f" <- <- {t}")
|
|
597
|
+
|
|
598
|
+
affected_modules = set()
|
|
599
|
+
for f in all_dependents:
|
|
600
|
+
parts = f.split("/")
|
|
601
|
+
if len(parts) > 1:
|
|
602
|
+
affected_modules.add(parts[0])
|
|
603
|
+
|
|
604
|
+
if affected_modules:
|
|
605
|
+
print(f"\nAffected modules: {', '.join(sorted(affected_modules))}")
|
|
606
|
+
|
|
607
|
+
total = 1 + len(all_dependents)
|
|
608
|
+
risk = "LOW" if total <= 3 else ("MEDIUM" if total <= 10 else "HIGH")
|
|
609
|
+
print(f"\nBlast radius: {total} file(s), risk: {risk}")
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _cmd_observe(args):
|
|
613
|
+
from pathlib import Path
|
|
614
|
+
from .storage.session_manager import SessionManager
|
|
615
|
+
from .discovery import find_repo_root
|
|
616
|
+
from .visibility import format_observation_delta
|
|
617
|
+
|
|
618
|
+
root = find_repo_root()
|
|
619
|
+
if root is None:
|
|
620
|
+
raise ValueError("Could not find repository root")
|
|
621
|
+
|
|
622
|
+
mgr = SessionManager(root)
|
|
623
|
+
obs = mgr.record_observation(args.commit)
|
|
624
|
+
|
|
625
|
+
if obs:
|
|
626
|
+
# Find the session to get scope_expr
|
|
627
|
+
sessions = mgr.get_sessions(limit=50)
|
|
628
|
+
scope_expr = "unknown"
|
|
629
|
+
for s in sessions:
|
|
630
|
+
if s.session_id == obs.session_id:
|
|
631
|
+
scope_expr = s.scope_expr
|
|
632
|
+
break
|
|
633
|
+
|
|
634
|
+
# Onboarding: mark first observation + increment counter
|
|
635
|
+
try:
|
|
636
|
+
from .storage.onboarding import mark_milestone, increment_counter
|
|
637
|
+
mark_milestone(root, "first_observation")
|
|
638
|
+
increment_counter(root, "observations_recorded")
|
|
639
|
+
except Exception:
|
|
640
|
+
pass
|
|
641
|
+
|
|
642
|
+
delta = format_observation_delta(obs, scope_expr)
|
|
643
|
+
try:
|
|
644
|
+
print(delta, file=sys.stderr)
|
|
645
|
+
except UnicodeEncodeError:
|
|
646
|
+
print(delta.encode("ascii", errors="replace").decode("ascii"),
|
|
647
|
+
file=sys.stderr)
|
|
648
|
+
|
|
649
|
+
# Update utility scores after observation
|
|
650
|
+
try:
|
|
651
|
+
from .utility import compute_utility_scores, save_utility_scores
|
|
652
|
+
all_sessions = mgr.get_sessions(limit=500)
|
|
653
|
+
all_obs = mgr.get_observations(limit=500)
|
|
654
|
+
scores = compute_utility_scores(all_sessions, all_obs)
|
|
655
|
+
save_utility_scores(Path(root) / ".dotscope", scores)
|
|
656
|
+
except Exception:
|
|
657
|
+
pass # Utility update is best-effort
|
|
658
|
+
|
|
659
|
+
# Near-miss detection using structured warning pairs
|
|
660
|
+
try:
|
|
661
|
+
import subprocess
|
|
662
|
+
from .storage.near_miss import (
|
|
663
|
+
detect_near_misses as detect_nms,
|
|
664
|
+
store_near_misses, load_session_scopes,
|
|
665
|
+
)
|
|
666
|
+
from .discovery import find_scope
|
|
667
|
+
from .parser import parse_scope_file
|
|
668
|
+
|
|
669
|
+
# Get scopes from session or current observation
|
|
670
|
+
scope_name = scope_expr.split("+")[0].split("-")[0].split("@")[0]
|
|
671
|
+
session_scopes = load_session_scopes(root) or [scope_name]
|
|
672
|
+
|
|
673
|
+
# Build context map for all session scopes
|
|
674
|
+
scope_contexts = {}
|
|
675
|
+
for sn in session_scopes:
|
|
676
|
+
cfg_path = find_scope(sn, root=root)
|
|
677
|
+
if cfg_path:
|
|
678
|
+
cfg = parse_scope_file(cfg_path)
|
|
679
|
+
scope_contexts[sn] = cfg.context_str
|
|
680
|
+
|
|
681
|
+
if scope_contexts:
|
|
682
|
+
diff_result = subprocess.run(
|
|
683
|
+
["git", "diff", obs.commit_hash + "~1", obs.commit_hash],
|
|
684
|
+
cwd=root, capture_output=True, text=True, timeout=5,
|
|
685
|
+
)
|
|
686
|
+
if diff_result.returncode == 0 and diff_result.stdout:
|
|
687
|
+
nms = detect_nms(diff_result.stdout, scope_contexts)
|
|
688
|
+
for nm in nms:
|
|
689
|
+
print(
|
|
690
|
+
f"\ndotscope: near-miss detected\n"
|
|
691
|
+
f" {nm.event}\n"
|
|
692
|
+
f" Scope context: \"{nm.context_used}\"\n"
|
|
693
|
+
f" {nm.potential_impact}",
|
|
694
|
+
file=sys.stderr,
|
|
695
|
+
)
|
|
696
|
+
if nms:
|
|
697
|
+
store_near_misses(root, nms)
|
|
698
|
+
except Exception:
|
|
699
|
+
pass # Near-miss detection is best-effort
|
|
700
|
+
else:
|
|
701
|
+
# No session matched — check if any scopes exist
|
|
702
|
+
try:
|
|
703
|
+
from .discovery import load_index
|
|
704
|
+
idx = load_index(root)
|
|
705
|
+
if idx:
|
|
706
|
+
print(
|
|
707
|
+
"dotscope: observation recorded\n"
|
|
708
|
+
" Changed files don't match any recent session\n"
|
|
709
|
+
" This is normal for work done outside agent sessions",
|
|
710
|
+
file=sys.stderr,
|
|
711
|
+
)
|
|
712
|
+
else:
|
|
713
|
+
print(
|
|
714
|
+
"dotscope: observation recorded\n"
|
|
715
|
+
" No .scopes index found"
|
|
716
|
+
" -- consider running `dotscope ingest .`",
|
|
717
|
+
file=sys.stderr,
|
|
718
|
+
)
|
|
719
|
+
except Exception:
|
|
720
|
+
print(f"No matching session for commit {args.commit[:8]}",
|
|
721
|
+
file=sys.stderr)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _cmd_incremental(args):
|
|
725
|
+
"""Incremental scope update from a single commit."""
|
|
726
|
+
import subprocess
|
|
727
|
+
from .discovery import find_repo_root
|
|
728
|
+
from .passes.incremental import incremental_update
|
|
729
|
+
|
|
730
|
+
root = find_repo_root()
|
|
731
|
+
if root is None:
|
|
732
|
+
return
|
|
733
|
+
|
|
734
|
+
# Get changed files from the commit
|
|
735
|
+
try:
|
|
736
|
+
result = subprocess.run(
|
|
737
|
+
["git", "diff-tree", "--no-commit-id", "--name-status", "-r", args.commit],
|
|
738
|
+
cwd=root, capture_output=True, text=True, timeout=10,
|
|
739
|
+
)
|
|
740
|
+
if result.returncode != 0:
|
|
741
|
+
return
|
|
742
|
+
except Exception:
|
|
743
|
+
return
|
|
744
|
+
|
|
745
|
+
changed = []
|
|
746
|
+
added = []
|
|
747
|
+
deleted = []
|
|
748
|
+
for line in result.stdout.strip().splitlines():
|
|
749
|
+
parts = line.split("\t", 1)
|
|
750
|
+
if len(parts) != 2:
|
|
751
|
+
continue
|
|
752
|
+
status, filepath = parts[0].strip(), parts[1].strip()
|
|
753
|
+
changed.append(filepath)
|
|
754
|
+
if status == "A":
|
|
755
|
+
added.append(filepath)
|
|
756
|
+
elif status == "D":
|
|
757
|
+
deleted.append(filepath)
|
|
758
|
+
|
|
759
|
+
if changed:
|
|
760
|
+
try:
|
|
761
|
+
incremental_update(root, changed, added, deleted, args.commit)
|
|
762
|
+
except Exception:
|
|
763
|
+
pass # Incremental update is best-effort
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _cmd_hook(args):
|
|
767
|
+
from .storage.git_hooks import install_hook, uninstall_hook, hook_status
|
|
768
|
+
from .discovery import find_repo_root
|
|
769
|
+
|
|
770
|
+
root = find_repo_root()
|
|
771
|
+
if root is None:
|
|
772
|
+
raise ValueError("Could not find repository root")
|
|
773
|
+
|
|
774
|
+
if args.hook_action == "install":
|
|
775
|
+
result = install_hook(root)
|
|
776
|
+
print(result)
|
|
777
|
+
elif args.hook_action == "uninstall":
|
|
778
|
+
removed = uninstall_hook(root)
|
|
779
|
+
print("Hooks removed." if removed else "No hooks found.")
|
|
780
|
+
elif args.hook_action == "status":
|
|
781
|
+
print(hook_status(root))
|
|
782
|
+
elif args.hook_action == "claude":
|
|
783
|
+
from .storage.claude_hooks import install_claude_hook
|
|
784
|
+
result = install_claude_hook(root)
|
|
785
|
+
print(result)
|
|
786
|
+
else:
|
|
787
|
+
print("Usage: dotscope hook {install|uninstall|status|claude}")
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _cmd_backtest(args):
|
|
791
|
+
from .passes.backtest import backtest_scopes, format_backtest_report
|
|
792
|
+
from .discovery import find_repo_root, find_all_scopes
|
|
793
|
+
from .parser import parse_scope_file
|
|
794
|
+
|
|
795
|
+
root = find_repo_root()
|
|
796
|
+
if root is None:
|
|
797
|
+
raise ValueError("Could not find repository root")
|
|
798
|
+
|
|
799
|
+
scope_files = find_all_scopes(root)
|
|
800
|
+
if not scope_files:
|
|
801
|
+
print("No .scope files found. Run 'dotscope ingest' first.")
|
|
802
|
+
return
|
|
803
|
+
|
|
804
|
+
configs = []
|
|
805
|
+
for sf in scope_files:
|
|
806
|
+
try:
|
|
807
|
+
configs.append(parse_scope_file(sf))
|
|
808
|
+
except (ValueError, IOError):
|
|
809
|
+
continue
|
|
810
|
+
|
|
811
|
+
report = backtest_scopes(root, configs, n_commits=args.commits)
|
|
812
|
+
print(format_backtest_report(report))
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _cmd_utility(args):
|
|
816
|
+
from .discovery import find_repo_root
|
|
817
|
+
from .storage.session_manager import SessionManager
|
|
818
|
+
from .utility import compute_utility_scores
|
|
819
|
+
|
|
820
|
+
root = find_repo_root()
|
|
821
|
+
if root is None:
|
|
822
|
+
raise ValueError("Could not find repository root")
|
|
823
|
+
|
|
824
|
+
mgr = SessionManager(root)
|
|
825
|
+
sessions = mgr.get_sessions(limit=200)
|
|
826
|
+
observations = mgr.get_observations(limit=200)
|
|
827
|
+
|
|
828
|
+
if not observations:
|
|
829
|
+
print("No observations yet. Install the hook and make some commits first.")
|
|
830
|
+
print(" dotscope hook install")
|
|
831
|
+
return
|
|
832
|
+
|
|
833
|
+
scores = compute_utility_scores(sessions, observations)
|
|
834
|
+
# Filter to scope
|
|
835
|
+
scope_prefix = args.scope + "/"
|
|
836
|
+
relevant = {k: v for k, v in scores.items() if scope_prefix in k or args.scope in k}
|
|
837
|
+
|
|
838
|
+
if not relevant:
|
|
839
|
+
print(f"No utility data for scope '{args.scope}'")
|
|
840
|
+
return
|
|
841
|
+
|
|
842
|
+
print(f"Utility scores for {args.scope} ({len(relevant)} files):\n")
|
|
843
|
+
for path, score in sorted(relevant.items(), key=lambda x: -x[1].utility_ratio):
|
|
844
|
+
filled = int(score.utility_ratio * 10)
|
|
845
|
+
bar = "#" * filled + "." * (10 - filled)
|
|
846
|
+
_safe_print(f" {os.path.basename(path)}: {bar} {score.utility_ratio:.0%} "
|
|
847
|
+
f"({score.touch_count}/{score.resolve_count})")
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _cmd_virtual(args):
|
|
851
|
+
from .discovery import find_repo_root
|
|
852
|
+
from .passes.graph_builder import build_graph
|
|
853
|
+
from .passes.virtual import detect_virtual_scopes, format_virtual_scopes
|
|
854
|
+
|
|
855
|
+
root = find_repo_root()
|
|
856
|
+
if root is None:
|
|
857
|
+
raise ValueError("Could not find repository root")
|
|
858
|
+
|
|
859
|
+
graph = build_graph(root)
|
|
860
|
+
scopes = detect_virtual_scopes(graph)
|
|
861
|
+
print(format_virtual_scopes(scopes, root))
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _cmd_lessons(args):
|
|
865
|
+
from .discovery import find_repo_root
|
|
866
|
+
from .storage.session_manager import SessionManager
|
|
867
|
+
from .lessons import generate_lessons
|
|
868
|
+
|
|
869
|
+
root = find_repo_root()
|
|
870
|
+
if root is None:
|
|
871
|
+
raise ValueError("Could not find repository root")
|
|
872
|
+
|
|
873
|
+
mgr = SessionManager(root)
|
|
874
|
+
sessions = mgr.get_sessions(limit=200)
|
|
875
|
+
observations = mgr.get_observations(limit=200)
|
|
876
|
+
|
|
877
|
+
if not observations:
|
|
878
|
+
print("No observations yet. Install the hook and make some commits first.")
|
|
879
|
+
return
|
|
880
|
+
|
|
881
|
+
lessons = generate_lessons(sessions, observations, module=args.scope)
|
|
882
|
+
if not lessons:
|
|
883
|
+
print(f"No lessons for scope '{args.scope}' yet.")
|
|
884
|
+
return
|
|
885
|
+
|
|
886
|
+
print(f"Lessons for {args.scope}:\n")
|
|
887
|
+
for lesson in lessons:
|
|
888
|
+
filled = int(lesson.confidence * 5)
|
|
889
|
+
conf = "#" * filled + "." * (5 - filled)
|
|
890
|
+
_safe_print(f" [{conf}] {lesson.lesson_text}")
|
|
891
|
+
_safe_print(f" {lesson.observation}")
|
|
892
|
+
print()
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _cmd_invariants(args):
|
|
896
|
+
from .discovery import find_repo_root
|
|
897
|
+
from .passes.graph_builder import build_graph
|
|
898
|
+
from .lessons import detect_invariants
|
|
899
|
+
from .passes.history_miner import analyze_history
|
|
900
|
+
|
|
901
|
+
root = find_repo_root()
|
|
902
|
+
if root is None:
|
|
903
|
+
raise ValueError("Could not find repository root")
|
|
904
|
+
|
|
905
|
+
graph = build_graph(root)
|
|
906
|
+
history = analyze_history(root, max_commits=500)
|
|
907
|
+
all_modules = [m.directory for m in graph.modules]
|
|
908
|
+
|
|
909
|
+
invariants = detect_invariants(
|
|
910
|
+
graph.edges, args.scope, all_modules, history.commits_analyzed
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
if not invariants:
|
|
914
|
+
print(f"No invariants detected for scope '{args.scope}'")
|
|
915
|
+
return
|
|
916
|
+
|
|
917
|
+
high = [inv for inv in invariants if inv.confidence >= 0.8]
|
|
918
|
+
low = [inv for inv in invariants if inv.confidence < 0.8]
|
|
919
|
+
|
|
920
|
+
if high:
|
|
921
|
+
print(f"Strong boundaries for {args.scope}:\n")
|
|
922
|
+
for inv in high:
|
|
923
|
+
print(f" {inv.boundary}: no imports ({inv.commit_count} commits observed)")
|
|
924
|
+
|
|
925
|
+
if low:
|
|
926
|
+
print("\nWeak boundaries:\n")
|
|
927
|
+
for inv in low[:5]:
|
|
928
|
+
print(f" {inv.boundary}: no imports (low confidence, {inv.commit_count} commits)")
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _cmd_rebuild(args):
|
|
932
|
+
from .discovery import find_repo_root
|
|
933
|
+
from .storage.session_manager import SessionManager
|
|
934
|
+
from .utility import rebuild_utility
|
|
935
|
+
from .lessons import generate_lessons, save_lessons, detect_invariants, save_invariants
|
|
936
|
+
from .passes.graph_builder import build_graph
|
|
937
|
+
|
|
938
|
+
root = find_repo_root()
|
|
939
|
+
if root is None:
|
|
940
|
+
raise ValueError("Could not find repository root")
|
|
941
|
+
|
|
942
|
+
mgr = SessionManager(root)
|
|
943
|
+
mgr.ensure_initialized()
|
|
944
|
+
sessions = mgr.get_sessions(limit=1000)
|
|
945
|
+
observations = mgr.get_observations(limit=1000)
|
|
946
|
+
dot_dir = mgr.dot_dir
|
|
947
|
+
|
|
948
|
+
print("Rebuilding utility scores...", file=sys.stderr)
|
|
949
|
+
scores = rebuild_utility(dot_dir, sessions, observations)
|
|
950
|
+
print(f" {len(scores)} file scores computed")
|
|
951
|
+
|
|
952
|
+
print("Rebuilding lessons...", file=sys.stderr)
|
|
953
|
+
graph = build_graph(root)
|
|
954
|
+
all_modules = [m.directory for m in graph.modules]
|
|
955
|
+
for mod in all_modules:
|
|
956
|
+
lessons = generate_lessons(sessions, observations, module=mod)
|
|
957
|
+
if lessons:
|
|
958
|
+
save_lessons(dot_dir, mod, lessons)
|
|
959
|
+
print(f" {mod}: {len(lessons)} lesson(s)")
|
|
960
|
+
|
|
961
|
+
print("Rebuilding invariants...", file=sys.stderr)
|
|
962
|
+
from .passes.history_miner import analyze_history
|
|
963
|
+
history = analyze_history(root, max_commits=500)
|
|
964
|
+
for mod in all_modules:
|
|
965
|
+
invariants = detect_invariants(graph.edges, mod, all_modules, history.commits_analyzed)
|
|
966
|
+
if invariants:
|
|
967
|
+
save_invariants(dot_dir, mod, invariants)
|
|
968
|
+
print(f" {mod}: {len(invariants)} invariant(s)")
|
|
969
|
+
|
|
970
|
+
print("Done.")
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _cmd_check(args):
|
|
974
|
+
import json as json_mod
|
|
975
|
+
from .discovery import find_repo_root
|
|
976
|
+
from .passes.sentinel.checker import check_diff, check_staged, format_terminal
|
|
977
|
+
|
|
978
|
+
root = find_repo_root()
|
|
979
|
+
if root is None:
|
|
980
|
+
raise ValueError("Could not find repository root")
|
|
981
|
+
|
|
982
|
+
if args.backtest:
|
|
983
|
+
_cmd_check_backtest(root, args.commits, args.json_output)
|
|
984
|
+
return
|
|
985
|
+
|
|
986
|
+
if args.diff:
|
|
987
|
+
with open(args.diff, "r", encoding="utf-8") as f:
|
|
988
|
+
diff_text = f.read()
|
|
989
|
+
report = check_diff(
|
|
990
|
+
diff_text, root,
|
|
991
|
+
session_id=args.session,
|
|
992
|
+
acknowledge_ids=args.acknowledge,
|
|
993
|
+
)
|
|
994
|
+
else:
|
|
995
|
+
report = check_staged(root, session_id=args.session)
|
|
996
|
+
|
|
997
|
+
if args.json_output:
|
|
998
|
+
data = {
|
|
999
|
+
"passed": report.passed,
|
|
1000
|
+
"holds": [
|
|
1001
|
+
{
|
|
1002
|
+
"category": r.category.value,
|
|
1003
|
+
"severity": r.severity.value,
|
|
1004
|
+
"message": r.message,
|
|
1005
|
+
"file": r.file,
|
|
1006
|
+
"suggestion": r.suggestion,
|
|
1007
|
+
"acknowledge_id": r.acknowledge_id,
|
|
1008
|
+
"proposed_fix": {
|
|
1009
|
+
"file": r.proposed_fix.file,
|
|
1010
|
+
"reason": r.proposed_fix.reason,
|
|
1011
|
+
"predicted_sections": r.proposed_fix.predicted_sections,
|
|
1012
|
+
"confidence": r.proposed_fix.confidence,
|
|
1013
|
+
} if r.proposed_fix else None,
|
|
1014
|
+
}
|
|
1015
|
+
for r in report.holds
|
|
1016
|
+
],
|
|
1017
|
+
"notes": [
|
|
1018
|
+
{
|
|
1019
|
+
"category": r.category.value,
|
|
1020
|
+
"severity": r.severity.value,
|
|
1021
|
+
"message": r.message,
|
|
1022
|
+
"file": r.file,
|
|
1023
|
+
}
|
|
1024
|
+
for r in report.notes
|
|
1025
|
+
],
|
|
1026
|
+
"files_checked": report.files_checked,
|
|
1027
|
+
}
|
|
1028
|
+
print(json_mod.dumps(data, indent=2))
|
|
1029
|
+
else:
|
|
1030
|
+
output = format_terminal(report)
|
|
1031
|
+
try:
|
|
1032
|
+
print(output)
|
|
1033
|
+
except UnicodeEncodeError:
|
|
1034
|
+
print(output.encode("ascii", errors="replace").decode("ascii"))
|
|
1035
|
+
|
|
1036
|
+
if not report.passed:
|
|
1037
|
+
sys.exit(1)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _cmd_check_backtest(root, n_commits, json_output):
|
|
1041
|
+
"""Replay recent commits against checks to validate enforcement."""
|
|
1042
|
+
import json as json_mod
|
|
1043
|
+
import subprocess
|
|
1044
|
+
from .passes.sentinel.checker import check_diff
|
|
1045
|
+
|
|
1046
|
+
try:
|
|
1047
|
+
result = subprocess.run(
|
|
1048
|
+
["git", "log", f"--max-count={n_commits}", "--pretty=format:%H|%s"],
|
|
1049
|
+
cwd=root, capture_output=True, text=True, timeout=15,
|
|
1050
|
+
)
|
|
1051
|
+
if result.returncode != 0:
|
|
1052
|
+
print("Could not read git log", file=sys.stderr)
|
|
1053
|
+
return
|
|
1054
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
1055
|
+
print("git not available", file=sys.stderr)
|
|
1056
|
+
return
|
|
1057
|
+
|
|
1058
|
+
commits = []
|
|
1059
|
+
for line in result.stdout.strip().splitlines():
|
|
1060
|
+
if "|" in line:
|
|
1061
|
+
h, msg = line.split("|", 1)
|
|
1062
|
+
commits.append((h.strip(), msg.strip()))
|
|
1063
|
+
|
|
1064
|
+
if not commits:
|
|
1065
|
+
print("No commits found")
|
|
1066
|
+
return
|
|
1067
|
+
|
|
1068
|
+
print(f"dotscope: replaying last {len(commits)} commits\n")
|
|
1069
|
+
|
|
1070
|
+
clean = 0
|
|
1071
|
+
total_holds = 0
|
|
1072
|
+
total_notes = 0
|
|
1073
|
+
|
|
1074
|
+
for commit_hash, message in commits:
|
|
1075
|
+
try:
|
|
1076
|
+
diff_result = subprocess.run(
|
|
1077
|
+
["git", "diff", commit_hash + "~1", commit_hash],
|
|
1078
|
+
cwd=root, capture_output=True, text=True, timeout=10,
|
|
1079
|
+
)
|
|
1080
|
+
if diff_result.returncode != 0 or not diff_result.stdout:
|
|
1081
|
+
continue
|
|
1082
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
1083
|
+
continue
|
|
1084
|
+
|
|
1085
|
+
report = check_diff(diff_result.stdout, root)
|
|
1086
|
+
|
|
1087
|
+
if report.passed and not report.notes:
|
|
1088
|
+
clean += 1
|
|
1089
|
+
continue
|
|
1090
|
+
|
|
1091
|
+
print(f" commit {commit_hash[:7]} \"{message}\"")
|
|
1092
|
+
for r in report.holds:
|
|
1093
|
+
print(f" HOLD {r.category.value}")
|
|
1094
|
+
print(f" {r.message}")
|
|
1095
|
+
total_holds += 1
|
|
1096
|
+
for r in report.notes:
|
|
1097
|
+
print(f" NOTE {r.category.value}")
|
|
1098
|
+
print(f" {r.message}")
|
|
1099
|
+
total_notes += 1
|
|
1100
|
+
print()
|
|
1101
|
+
|
|
1102
|
+
print(f" {clean} commits clean, {total_holds} hold(s), {total_notes} note(s)")
|
|
1103
|
+
if total_holds:
|
|
1104
|
+
print(f" dotscope would have caught {total_holds} issue(s) before they shipped")
|
|
1105
|
+
|
|
1106
|
+
# Onboarding: mark backtest milestone + show next step
|
|
1107
|
+
try:
|
|
1108
|
+
from .storage.onboarding import mark_milestone, next_step
|
|
1109
|
+
state = mark_milestone(root, "first_backtest")
|
|
1110
|
+
ns = next_step(state)
|
|
1111
|
+
if ns:
|
|
1112
|
+
print(f"\n{ns}")
|
|
1113
|
+
except Exception:
|
|
1114
|
+
pass
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _cmd_test_compiler(args):
|
|
1118
|
+
from .discovery import find_repo_root
|
|
1119
|
+
from .regression import load_regressions, replay_regression, format_replay_report
|
|
1120
|
+
|
|
1121
|
+
root = find_repo_root()
|
|
1122
|
+
if root is None:
|
|
1123
|
+
raise ValueError("Could not find repository root")
|
|
1124
|
+
|
|
1125
|
+
cases = load_regressions(root)
|
|
1126
|
+
if args.scope:
|
|
1127
|
+
cases = [c for c in cases if args.scope in c.scope_expr]
|
|
1128
|
+
|
|
1129
|
+
if not cases:
|
|
1130
|
+
print("No regression cases found.")
|
|
1131
|
+
print("Sessions are auto-frozen after successful observations (recall >= 80%).")
|
|
1132
|
+
return
|
|
1133
|
+
|
|
1134
|
+
results = []
|
|
1135
|
+
for case in cases:
|
|
1136
|
+
try:
|
|
1137
|
+
result = replay_regression(case, root)
|
|
1138
|
+
results.append(result)
|
|
1139
|
+
except Exception as e:
|
|
1140
|
+
print(f" {case.id}: error: {e}", file=sys.stderr)
|
|
1141
|
+
|
|
1142
|
+
print(format_replay_report(results))
|
|
1143
|
+
regressions = sum(1 for r in results if r.is_regression)
|
|
1144
|
+
if regressions:
|
|
1145
|
+
sys.exit(1)
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _cmd_bench(args):
|
|
1149
|
+
import json as json_mod
|
|
1150
|
+
from .discovery import find_repo_root
|
|
1151
|
+
from .bench import run_bench, format_bench_report
|
|
1152
|
+
|
|
1153
|
+
root = find_repo_root()
|
|
1154
|
+
if root is None:
|
|
1155
|
+
raise ValueError("Could not find repository root")
|
|
1156
|
+
|
|
1157
|
+
report = run_bench(root)
|
|
1158
|
+
|
|
1159
|
+
if hasattr(args, "json_output") and args.json_output:
|
|
1160
|
+
from dataclasses import asdict
|
|
1161
|
+
print(json_mod.dumps(asdict(report), indent=2))
|
|
1162
|
+
else:
|
|
1163
|
+
print(format_bench_report(report))
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def _cmd_debug(args):
|
|
1167
|
+
from .discovery import find_repo_root
|
|
1168
|
+
from .debug import debug_session, list_bad_sessions, format_debug_report
|
|
1169
|
+
|
|
1170
|
+
root = find_repo_root()
|
|
1171
|
+
if root is None:
|
|
1172
|
+
raise ValueError("Could not find repository root")
|
|
1173
|
+
|
|
1174
|
+
if hasattr(args, "list_bad") and args.list_bad:
|
|
1175
|
+
bad = list_bad_sessions(root)
|
|
1176
|
+
if not bad:
|
|
1177
|
+
print("No sessions with low recall found.")
|
|
1178
|
+
return
|
|
1179
|
+
for s in bad:
|
|
1180
|
+
gaps = ", ".join(s["gaps"][:2]) if s["gaps"] else "none"
|
|
1181
|
+
print(f" {s['session_id'][:12]} {s['scope']} recall: {s['recall']:.0%} gaps: {gaps}")
|
|
1182
|
+
return
|
|
1183
|
+
|
|
1184
|
+
session_id = args.session_id
|
|
1185
|
+
if hasattr(args, "last") and args.last:
|
|
1186
|
+
bad = list_bad_sessions(root, limit=1)
|
|
1187
|
+
if bad:
|
|
1188
|
+
session_id = bad[0]["session_id"]
|
|
1189
|
+
else:
|
|
1190
|
+
print("No sessions with low recall found.")
|
|
1191
|
+
return
|
|
1192
|
+
|
|
1193
|
+
if not session_id:
|
|
1194
|
+
print("Usage: dotscope debug <session_id> or dotscope debug --last")
|
|
1195
|
+
return
|
|
1196
|
+
|
|
1197
|
+
result = debug_session(session_id, root)
|
|
1198
|
+
if result is None:
|
|
1199
|
+
print(f"Session {session_id} not found or has good recall (>= 80%).")
|
|
1200
|
+
return
|
|
1201
|
+
|
|
1202
|
+
print(format_debug_report(result))
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _cmd_intent(args):
|
|
1206
|
+
from .discovery import find_repo_root
|
|
1207
|
+
from .intent import load_intents, add_intent, remove_intent
|
|
1208
|
+
|
|
1209
|
+
root = find_repo_root()
|
|
1210
|
+
if root is None:
|
|
1211
|
+
raise ValueError("Could not find repository root")
|
|
1212
|
+
|
|
1213
|
+
if args.intent_action == "add":
|
|
1214
|
+
intent = add_intent(
|
|
1215
|
+
root,
|
|
1216
|
+
directive=args.directive,
|
|
1217
|
+
targets=args.targets,
|
|
1218
|
+
reason=args.reason,
|
|
1219
|
+
replacement=args.replacement,
|
|
1220
|
+
target=args.target,
|
|
1221
|
+
)
|
|
1222
|
+
print(f"Added intent: {intent.directive} (id: {intent.id})")
|
|
1223
|
+
|
|
1224
|
+
elif args.intent_action == "list":
|
|
1225
|
+
intents = load_intents(root)
|
|
1226
|
+
if not intents:
|
|
1227
|
+
print("No intents defined. Use 'dotscope intent add' to declare architectural direction.")
|
|
1228
|
+
return
|
|
1229
|
+
for intent in intents:
|
|
1230
|
+
targets = ", ".join(intent.modules + intent.files)
|
|
1231
|
+
print(f" [{intent.id}] {intent.directive} {targets}")
|
|
1232
|
+
if intent.reason:
|
|
1233
|
+
print(f" {intent.reason}")
|
|
1234
|
+
print(f" set by {intent.set_by} on {intent.set_at}")
|
|
1235
|
+
print()
|
|
1236
|
+
|
|
1237
|
+
elif args.intent_action == "remove":
|
|
1238
|
+
removed = remove_intent(root, args.id)
|
|
1239
|
+
print("Removed." if removed else f"Intent not found: {args.id}")
|
|
1240
|
+
|
|
1241
|
+
else:
|
|
1242
|
+
print("Usage: dotscope intent {add|list|remove}")
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def _cmd_voice(args):
|
|
1246
|
+
import json as json_mod
|
|
1247
|
+
from .discovery import find_repo_root
|
|
1248
|
+
from .intent import load_voice_config
|
|
1249
|
+
|
|
1250
|
+
root = find_repo_root(".")
|
|
1251
|
+
voice = load_voice_config(root)
|
|
1252
|
+
|
|
1253
|
+
if voice is None:
|
|
1254
|
+
print("No voice discovered. Run `dotscope ingest .` first.", file=sys.stderr)
|
|
1255
|
+
return
|
|
1256
|
+
|
|
1257
|
+
if getattr(args, "json", False):
|
|
1258
|
+
print(json_mod.dumps(voice, indent=2, default=str))
|
|
1259
|
+
return
|
|
1260
|
+
|
|
1261
|
+
if getattr(args, "upgrade", None):
|
|
1262
|
+
rule = args.upgrade
|
|
1263
|
+
enforce = voice.get("enforce", {})
|
|
1264
|
+
current = enforce.get(rule)
|
|
1265
|
+
if current is False:
|
|
1266
|
+
enforce[rule] = "note"
|
|
1267
|
+
elif current == "note":
|
|
1268
|
+
enforce[rule] = "hold"
|
|
1269
|
+
else:
|
|
1270
|
+
print(f"{rule}: already at highest enforcement level", file=sys.stderr)
|
|
1271
|
+
return
|
|
1272
|
+
|
|
1273
|
+
# Save back
|
|
1274
|
+
from .models.intent import DiscoveredVoice
|
|
1275
|
+
dv = DiscoveredVoice(
|
|
1276
|
+
mode=voice.get("mode", "adaptive"),
|
|
1277
|
+
rules=voice.get("rules", {}),
|
|
1278
|
+
stats=voice.get("stats", {}),
|
|
1279
|
+
enforce=enforce,
|
|
1280
|
+
)
|
|
1281
|
+
from .intent import save_voice_config
|
|
1282
|
+
save_voice_config(root, dv)
|
|
1283
|
+
print(f"{rule}: upgraded to {enforce[rule]}")
|
|
1284
|
+
return
|
|
1285
|
+
|
|
1286
|
+
# Default: show voice config
|
|
1287
|
+
print(f"Mode: {voice.get('mode', 'adaptive')}")
|
|
1288
|
+
print()
|
|
1289
|
+
rules = voice.get("rules", {})
|
|
1290
|
+
if rules:
|
|
1291
|
+
for key, val in rules.items():
|
|
1292
|
+
val_short = val.strip().splitlines()[0] if val else ""
|
|
1293
|
+
print(f" {key}: {val_short}")
|
|
1294
|
+
print()
|
|
1295
|
+
enforce = voice.get("enforce", {})
|
|
1296
|
+
if enforce:
|
|
1297
|
+
print("Enforcement:")
|
|
1298
|
+
for key, val in enforce.items():
|
|
1299
|
+
label = str(val) if val is not False else "off"
|
|
1300
|
+
print(f" {key}: {label}")
|
|
1301
|
+
stats = voice.get("stats", {})
|
|
1302
|
+
if stats:
|
|
1303
|
+
print()
|
|
1304
|
+
print("Stats:")
|
|
1305
|
+
for key, val in stats.items():
|
|
1306
|
+
if val is not None:
|
|
1307
|
+
print(f" {key}: {val}")
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _cmd_conventions(args):
|
|
1311
|
+
import json as json_mod
|
|
1312
|
+
from .discovery import find_repo_root
|
|
1313
|
+
root = find_repo_root(os.getcwd()) or os.getcwd()
|
|
1314
|
+
|
|
1315
|
+
if args.discover:
|
|
1316
|
+
from .passes.graph_builder import build_graph
|
|
1317
|
+
from .passes.convention_discovery import discover_conventions
|
|
1318
|
+
from .passes.convention_parser import parse_conventions
|
|
1319
|
+
from .passes.convention_compliance import compute_compliance
|
|
1320
|
+
|
|
1321
|
+
print("Analyzing codebase...", file=sys.stderr)
|
|
1322
|
+
graph = build_graph(root)
|
|
1323
|
+
if not graph.apis:
|
|
1324
|
+
print("No source files found to analyze.", file=sys.stderr)
|
|
1325
|
+
return
|
|
1326
|
+
|
|
1327
|
+
discovered = discover_conventions(graph.apis, graph)
|
|
1328
|
+
if not discovered:
|
|
1329
|
+
print("No conventions discovered.", file=sys.stderr)
|
|
1330
|
+
return
|
|
1331
|
+
|
|
1332
|
+
nodes = parse_conventions(graph.apis, discovered)
|
|
1333
|
+
for conv in discovered:
|
|
1334
|
+
conv.compliance = compute_compliance(conv, nodes, graph.apis)
|
|
1335
|
+
|
|
1336
|
+
viable = [c for c in discovered if c.compliance >= 0.5]
|
|
1337
|
+
|
|
1338
|
+
print(f"\nDiscovered {len(viable)} conventions:\n")
|
|
1339
|
+
for conv in viable:
|
|
1340
|
+
print(f' "{conv.name}" -- {conv.description}')
|
|
1341
|
+
if conv.rules.get("required_methods"):
|
|
1342
|
+
print(f" Required methods: {', '.join(conv.rules['required_methods'])}")
|
|
1343
|
+
if conv.rules.get("prohibited_imports"):
|
|
1344
|
+
print(f" Prohibited imports: {', '.join(conv.rules['prohibited_imports'])}")
|
|
1345
|
+
print(f" Compliance: {conv.compliance:.0%}")
|
|
1346
|
+
print()
|
|
1347
|
+
|
|
1348
|
+
if args.accept:
|
|
1349
|
+
from .intent import save_conventions
|
|
1350
|
+
save_conventions(root, viable)
|
|
1351
|
+
print(f"Accepted {len(viable)} conventions. Written to intent.yaml.")
|
|
1352
|
+
else:
|
|
1353
|
+
print("Run with --accept to persist, or edit manually in intent.yaml.")
|
|
1354
|
+
return
|
|
1355
|
+
|
|
1356
|
+
# List existing conventions
|
|
1357
|
+
from .intent import load_conventions
|
|
1358
|
+
conventions = load_conventions(root)
|
|
1359
|
+
|
|
1360
|
+
if not conventions:
|
|
1361
|
+
print("No conventions defined. Run 'dotscope conventions --discover' to find patterns.")
|
|
1362
|
+
return
|
|
1363
|
+
|
|
1364
|
+
if getattr(args, "json_output", False):
|
|
1365
|
+
data = [
|
|
1366
|
+
{
|
|
1367
|
+
"name": c.name,
|
|
1368
|
+
"source": c.source,
|
|
1369
|
+
"description": c.description,
|
|
1370
|
+
"compliance": c.compliance,
|
|
1371
|
+
"rules": c.rules,
|
|
1372
|
+
}
|
|
1373
|
+
for c in conventions
|
|
1374
|
+
]
|
|
1375
|
+
print(json_mod.dumps(data, indent=2))
|
|
1376
|
+
else:
|
|
1377
|
+
print(f"{len(conventions)} conventions:\n")
|
|
1378
|
+
for conv in conventions:
|
|
1379
|
+
severity = "HOLD" if conv.compliance >= 0.80 else "NOTE" if conv.compliance >= 0.50 else "RETIRED"
|
|
1380
|
+
print(f" [{severity}] {conv.name} ({conv.compliance:.0%} compliance)")
|
|
1381
|
+
if conv.description:
|
|
1382
|
+
print(f" {conv.description}")
|
|
1383
|
+
print()
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _cmd_diff(args):
|
|
1387
|
+
import json as json_mod
|
|
1388
|
+
import subprocess
|
|
1389
|
+
from .discovery import find_repo_root
|
|
1390
|
+
root = find_repo_root(os.getcwd()) or os.getcwd()
|
|
1391
|
+
|
|
1392
|
+
# Get diff text
|
|
1393
|
+
if args.staged:
|
|
1394
|
+
result = subprocess.run(
|
|
1395
|
+
["git", "diff", "--cached"], cwd=root,
|
|
1396
|
+
capture_output=True, text=True, timeout=10,
|
|
1397
|
+
)
|
|
1398
|
+
diff_text = result.stdout
|
|
1399
|
+
elif args.ref:
|
|
1400
|
+
result = subprocess.run(
|
|
1401
|
+
["git", "diff", args.ref], cwd=root,
|
|
1402
|
+
capture_output=True, text=True, timeout=10,
|
|
1403
|
+
)
|
|
1404
|
+
diff_text = result.stdout
|
|
1405
|
+
else:
|
|
1406
|
+
result = subprocess.run(
|
|
1407
|
+
["git", "diff"], cwd=root,
|
|
1408
|
+
capture_output=True, text=True, timeout=10,
|
|
1409
|
+
)
|
|
1410
|
+
diff_text = result.stdout
|
|
1411
|
+
|
|
1412
|
+
if not diff_text:
|
|
1413
|
+
print("No changes to diff.")
|
|
1414
|
+
return
|
|
1415
|
+
|
|
1416
|
+
from .intent import load_conventions
|
|
1417
|
+
from .passes.semantic_diff import semantic_diff, format_semantic_diff
|
|
1418
|
+
|
|
1419
|
+
conventions = load_conventions(root)
|
|
1420
|
+
if not conventions:
|
|
1421
|
+
print("No conventions defined. Run 'dotscope conventions --discover' first.")
|
|
1422
|
+
return
|
|
1423
|
+
|
|
1424
|
+
report = semantic_diff(diff_text, root, conventions)
|
|
1425
|
+
|
|
1426
|
+
if getattr(args, "json_output", False):
|
|
1427
|
+
data = {
|
|
1428
|
+
"added": [{"name": n.name, "file": n.file_path} for n in report.added],
|
|
1429
|
+
"removed": [{"name": n.name, "file": n.file_path} for n in report.removed],
|
|
1430
|
+
"modified": [
|
|
1431
|
+
{"name": a.name, "file": a.file_path, "violations": a.violations}
|
|
1432
|
+
for _, a in report.modified
|
|
1433
|
+
],
|
|
1434
|
+
"all_upheld": report.all_conventions_upheld,
|
|
1435
|
+
}
|
|
1436
|
+
print(json_mod.dumps(data, indent=2))
|
|
1437
|
+
else:
|
|
1438
|
+
print(format_semantic_diff(report))
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _version():
|
|
1442
|
+
from . import __version__
|
|
1443
|
+
return __version__
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
if __name__ == "__main__":
|
|
1447
|
+
main()
|