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/composer.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Scope composition algebra.
|
|
2
|
+
|
|
3
|
+
Supports expressions like:
|
|
4
|
+
auth+payments — merge (union of files, concatenate context)
|
|
5
|
+
auth-tests — subtract (remove files matching subtracted scope)
|
|
6
|
+
auth&api — intersect (only files in both)
|
|
7
|
+
auth@context — modifier (context only, no files)
|
|
8
|
+
|
|
9
|
+
Operators bind left-to-right, no precedence. @ is a suffix modifier.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import List, Optional
|
|
17
|
+
|
|
18
|
+
from .discovery import find_scope, find_repo_root
|
|
19
|
+
from .models import ResolvedScope
|
|
20
|
+
from .resolver import resolve
|
|
21
|
+
from .tokens import estimate_scope_tokens, estimate_context_tokens
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Op(Enum):
|
|
25
|
+
MERGE = "+"
|
|
26
|
+
SUBTRACT = "-"
|
|
27
|
+
INTERSECT = "&"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ScopeRef:
|
|
32
|
+
"""A reference to a scope with optional modifier."""
|
|
33
|
+
name: str
|
|
34
|
+
context_only: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ScopeOp:
|
|
39
|
+
"""An operation in a scope expression."""
|
|
40
|
+
operator: Optional[Op] # None for the first operand
|
|
41
|
+
ref: ScopeRef
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_expression(expr: str) -> List[ScopeOp]:
|
|
45
|
+
"""Parse a scope expression into a list of operations.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
"auth" → [ScopeOp(None, ScopeRef("auth"))]
|
|
49
|
+
"auth+payments" → [ScopeOp(None, "auth"), ScopeOp(MERGE, "payments")]
|
|
50
|
+
"auth-tests" → [ScopeOp(None, "auth"), ScopeOp(SUBTRACT, "tests")]
|
|
51
|
+
"auth@context" → [ScopeOp(None, ScopeRef("auth", context_only=True))]
|
|
52
|
+
"""
|
|
53
|
+
expr = expr.strip()
|
|
54
|
+
if not expr:
|
|
55
|
+
raise ValueError("Empty scope expression")
|
|
56
|
+
|
|
57
|
+
# Tokenize on operators, keeping the operators
|
|
58
|
+
tokens = re.split(r"([+\-&])", expr)
|
|
59
|
+
tokens = [t.strip() for t in tokens if t.strip()]
|
|
60
|
+
|
|
61
|
+
ops: List[ScopeOp] = []
|
|
62
|
+
current_op: Optional[Op] = None
|
|
63
|
+
|
|
64
|
+
for token in tokens:
|
|
65
|
+
if token in ("+", "-", "&"):
|
|
66
|
+
current_op = Op(token)
|
|
67
|
+
else:
|
|
68
|
+
ref = _parse_ref(token)
|
|
69
|
+
ops.append(ScopeOp(operator=current_op, ref=ref))
|
|
70
|
+
current_op = None
|
|
71
|
+
|
|
72
|
+
if not ops:
|
|
73
|
+
raise ValueError(f"Invalid scope expression: {expr}")
|
|
74
|
+
|
|
75
|
+
return ops
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_ref(token: str) -> ScopeRef:
|
|
79
|
+
"""Parse a scope reference, handling @modifier."""
|
|
80
|
+
if "@" in token:
|
|
81
|
+
name, modifier = token.split("@", 1)
|
|
82
|
+
if modifier == "context":
|
|
83
|
+
return ScopeRef(name=name, context_only=True)
|
|
84
|
+
else:
|
|
85
|
+
raise ValueError(f"Unknown modifier @{modifier}. Supported: @context")
|
|
86
|
+
return ScopeRef(name=token)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def compose(
|
|
90
|
+
expr: str,
|
|
91
|
+
root: Optional[str] = None,
|
|
92
|
+
follow_related: bool = True,
|
|
93
|
+
) -> ResolvedScope:
|
|
94
|
+
"""Resolve a scope expression to a ResolvedScope.
|
|
95
|
+
|
|
96
|
+
This is the main entry point for scope composition.
|
|
97
|
+
"""
|
|
98
|
+
if root is None:
|
|
99
|
+
root = find_repo_root()
|
|
100
|
+
if root is None:
|
|
101
|
+
raise ValueError("Could not find repository root. No .scopes, .git, or .scope found.")
|
|
102
|
+
|
|
103
|
+
ops = parse_expression(expr)
|
|
104
|
+
result: Optional[ResolvedScope] = None
|
|
105
|
+
|
|
106
|
+
for op in ops:
|
|
107
|
+
# Resolve the scope reference
|
|
108
|
+
config = find_scope(op.ref.name, root)
|
|
109
|
+
if config is None:
|
|
110
|
+
# Lazy ingest: generate scope on demand
|
|
111
|
+
from .passes.lazy import lazy_ingest_module
|
|
112
|
+
config = lazy_ingest_module(root, op.ref.name)
|
|
113
|
+
if config is None:
|
|
114
|
+
raise ValueError(f"Scope not found: {op.ref.name}")
|
|
115
|
+
|
|
116
|
+
resolved = resolve(config, follow_related=follow_related, root=root)
|
|
117
|
+
|
|
118
|
+
# Apply @context modifier
|
|
119
|
+
if op.ref.context_only:
|
|
120
|
+
resolved = ResolvedScope(
|
|
121
|
+
files=[],
|
|
122
|
+
context=resolved.context,
|
|
123
|
+
token_estimate=estimate_context_tokens(resolved.context),
|
|
124
|
+
scope_chain=resolved.scope_chain,
|
|
125
|
+
truncated=False,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Apply operator
|
|
129
|
+
if result is None:
|
|
130
|
+
result = resolved
|
|
131
|
+
elif op.operator == Op.MERGE:
|
|
132
|
+
result = result.merge(resolved)
|
|
133
|
+
elif op.operator == Op.SUBTRACT:
|
|
134
|
+
result = result.subtract(resolved)
|
|
135
|
+
# Recalculate tokens after subtraction
|
|
136
|
+
result.token_estimate = (
|
|
137
|
+
estimate_scope_tokens(result.files)
|
|
138
|
+
+ estimate_context_tokens(result.context)
|
|
139
|
+
)
|
|
140
|
+
elif op.operator == Op.INTERSECT:
|
|
141
|
+
result = result.intersect(resolved)
|
|
142
|
+
result.token_estimate = (
|
|
143
|
+
estimate_scope_tokens(result.files)
|
|
144
|
+
+ estimate_context_tokens(result.context)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return result or ResolvedScope()
|
dotscope/constants.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Shared constants — single source of truth for all modules."""
|
|
2
|
+
|
|
3
|
+
# Directories to always skip when walking a codebase
|
|
4
|
+
SKIP_DIRS = frozenset({
|
|
5
|
+
".git",
|
|
6
|
+
"node_modules",
|
|
7
|
+
"__pycache__",
|
|
8
|
+
"venv",
|
|
9
|
+
".venv",
|
|
10
|
+
"env",
|
|
11
|
+
".env",
|
|
12
|
+
"dist",
|
|
13
|
+
"build",
|
|
14
|
+
".tox",
|
|
15
|
+
".mypy_cache",
|
|
16
|
+
".ruff_cache",
|
|
17
|
+
".eggs",
|
|
18
|
+
".pytest_cache",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
# Source file extensions
|
|
22
|
+
SOURCE_EXTS = frozenset({
|
|
23
|
+
".py", ".js", ".ts", ".tsx", ".jsx",
|
|
24
|
+
".go", ".rs", ".rb", ".java", ".kt",
|
|
25
|
+
".swift", ".c", ".cpp", ".cs", ".php",
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
# Extension → language name
|
|
29
|
+
LANG_MAP = {
|
|
30
|
+
".py": "Python",
|
|
31
|
+
".js": "JavaScript",
|
|
32
|
+
".ts": "TypeScript",
|
|
33
|
+
".tsx": "TypeScript",
|
|
34
|
+
".jsx": "JavaScript",
|
|
35
|
+
".go": "Go",
|
|
36
|
+
".rs": "Rust",
|
|
37
|
+
".rb": "Ruby",
|
|
38
|
+
".java": "Java",
|
|
39
|
+
".kt": "Kotlin",
|
|
40
|
+
".swift": "Swift",
|
|
41
|
+
".c": "C",
|
|
42
|
+
".cpp": "C++",
|
|
43
|
+
".cs": "C#",
|
|
44
|
+
".php": "PHP",
|
|
45
|
+
}
|
dotscope/context.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Structured context parsing and querying.
|
|
2
|
+
|
|
3
|
+
Convention: ## Section Name headers within a context block create named sections.
|
|
4
|
+
Agents can query specific sections (e.g., "gotchas", "invariants").
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .models import StructuredContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_context(raw: str) -> StructuredContext:
|
|
15
|
+
"""Parse a context string into a StructuredContext with optional sections.
|
|
16
|
+
|
|
17
|
+
Sections are delimited by ## headers within the context block:
|
|
18
|
+
## Invariants
|
|
19
|
+
Some invariant text...
|
|
20
|
+
|
|
21
|
+
## Gotchas
|
|
22
|
+
Watch out for...
|
|
23
|
+
|
|
24
|
+
If no ## headers are found, the entire string is the raw context with no sections.
|
|
25
|
+
"""
|
|
26
|
+
if not raw or not raw.strip():
|
|
27
|
+
return StructuredContext(raw="", sections={})
|
|
28
|
+
|
|
29
|
+
raw = raw.strip()
|
|
30
|
+
sections: dict[str, str] = {}
|
|
31
|
+
|
|
32
|
+
# Split on ## headers
|
|
33
|
+
parts = re.split(r"^##\s+(.+)$", raw, flags=re.MULTILINE)
|
|
34
|
+
|
|
35
|
+
# parts[0] is text before any header (preamble)
|
|
36
|
+
# Then alternating: header_name, content, header_name, content, ...
|
|
37
|
+
if len(parts) <= 1:
|
|
38
|
+
# No sections found — the whole thing is raw context
|
|
39
|
+
return StructuredContext(raw=raw, sections={})
|
|
40
|
+
|
|
41
|
+
preamble = parts[0].strip()
|
|
42
|
+
i = 1
|
|
43
|
+
while i < len(parts) - 1:
|
|
44
|
+
section_name = parts[i].strip()
|
|
45
|
+
section_content = parts[i + 1].strip()
|
|
46
|
+
sections[section_name] = section_content
|
|
47
|
+
i += 2
|
|
48
|
+
|
|
49
|
+
# If there's a preamble, add it as a special section
|
|
50
|
+
if preamble:
|
|
51
|
+
sections["_preamble"] = preamble
|
|
52
|
+
|
|
53
|
+
return StructuredContext(raw=raw, sections=sections)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def query_context(ctx: Optional[StructuredContext], section: Optional[str] = None) -> str:
|
|
57
|
+
"""Query context, optionally filtering to a named section."""
|
|
58
|
+
if ctx is None:
|
|
59
|
+
return ""
|
|
60
|
+
return ctx.query(section)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Counterfactual detection: what didn't happen because dotscope was there.
|
|
2
|
+
|
|
3
|
+
Three sources:
|
|
4
|
+
1. Anti-patterns avoided (from near-miss data)
|
|
5
|
+
2. Contracts honored (agent modified both sides of a coupled pair)
|
|
6
|
+
3. Intents respected (agent didn't violate declared architectural direction)
|
|
7
|
+
|
|
8
|
+
Only surfaces counterfactuals where the constraint was in the resolve response
|
|
9
|
+
the agent received. Coincidences don't count.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Dict, List, Optional, Set
|
|
14
|
+
|
|
15
|
+
from .models.state import Counterfactual # noqa: F401
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compute_counterfactuals(
|
|
19
|
+
constraints_served: List[dict],
|
|
20
|
+
modified_files: Set[str],
|
|
21
|
+
diff_text: str,
|
|
22
|
+
near_misses: Optional[List] = None,
|
|
23
|
+
invariants: Optional[dict] = None,
|
|
24
|
+
intents: Optional[list] = None,
|
|
25
|
+
) -> List[Counterfactual]:
|
|
26
|
+
"""Compute what dotscope prevented during this session.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
constraints_served: Constraints the agent received via resolve_scope
|
|
30
|
+
modified_files: Files the agent actually modified
|
|
31
|
+
diff_text: Combined diff of all commits in this session
|
|
32
|
+
near_misses: Near-miss detections from observation
|
|
33
|
+
invariants: Cached invariants (contracts, stabilities)
|
|
34
|
+
intents: Architectural intents
|
|
35
|
+
"""
|
|
36
|
+
results: List[Counterfactual] = []
|
|
37
|
+
|
|
38
|
+
# 1. Anti-patterns avoided (from near-miss data)
|
|
39
|
+
if near_misses:
|
|
40
|
+
for nm in near_misses:
|
|
41
|
+
event = nm.get("event", "") if isinstance(nm, dict) else getattr(nm, "event", "")
|
|
42
|
+
scope = nm.get("scope", "") if isinstance(nm, dict) else getattr(nm, "scope", "")
|
|
43
|
+
if event:
|
|
44
|
+
results.append(Counterfactual(
|
|
45
|
+
type="anti_pattern_avoided",
|
|
46
|
+
description=event,
|
|
47
|
+
source=f"{scope} scope context" if scope else "scope context",
|
|
48
|
+
severity="high",
|
|
49
|
+
))
|
|
50
|
+
|
|
51
|
+
# 2. Contracts honored — agent modified both sides of a coupled pair
|
|
52
|
+
if invariants and modified_files:
|
|
53
|
+
served_contracts = _extract_served_contracts(constraints_served)
|
|
54
|
+
for contract in invariants.get("contracts", []):
|
|
55
|
+
trigger = contract.get("trigger_file", "")
|
|
56
|
+
coupled = contract.get("coupled_file", "")
|
|
57
|
+
confidence = contract.get("confidence", 0.0)
|
|
58
|
+
|
|
59
|
+
if confidence < 0.65:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
# Both modified AND the contract was in the constraints the agent saw
|
|
63
|
+
if (trigger in modified_files
|
|
64
|
+
and coupled in modified_files
|
|
65
|
+
and _contract_was_served(trigger, coupled, served_contracts)):
|
|
66
|
+
results.append(Counterfactual(
|
|
67
|
+
type="contract_honored",
|
|
68
|
+
description=f"Agent included {coupled} alongside {trigger}",
|
|
69
|
+
source=f"implicit contract ({confidence:.0%} co-change)",
|
|
70
|
+
severity="high",
|
|
71
|
+
))
|
|
72
|
+
|
|
73
|
+
# 3. Intents respected — agent didn't violate declared direction
|
|
74
|
+
if intents and modified_files and diff_text:
|
|
75
|
+
served_intents = _extract_served_intents(constraints_served)
|
|
76
|
+
for intent in intents:
|
|
77
|
+
if not isinstance(intent, dict):
|
|
78
|
+
directive = getattr(intent, "directive", "")
|
|
79
|
+
modules = getattr(intent, "modules", [])
|
|
80
|
+
else:
|
|
81
|
+
directive = intent.get("directive", "")
|
|
82
|
+
modules = intent.get("modules", [])
|
|
83
|
+
|
|
84
|
+
if directive != "decouple" or len(modules) < 2:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# Was this intent served to the agent?
|
|
88
|
+
if not _intent_was_served(directive, modules, served_intents):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Did the agent touch either module without new coupling?
|
|
92
|
+
touched_modules = set()
|
|
93
|
+
for f in modified_files:
|
|
94
|
+
for m in modules:
|
|
95
|
+
if f.startswith(m):
|
|
96
|
+
touched_modules.add(m)
|
|
97
|
+
|
|
98
|
+
if len(touched_modules) >= 1 and not _has_new_coupling(modules, diff_text):
|
|
99
|
+
mod_str = " and ".join(m.rstrip("/") for m in modules)
|
|
100
|
+
results.append(Counterfactual(
|
|
101
|
+
type="intent_respected",
|
|
102
|
+
description=f"Agent avoided new coupling between {mod_str}",
|
|
103
|
+
source=f"intent: decouple {' '.join(modules)}",
|
|
104
|
+
severity="medium",
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
return results
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_counterfactuals_terminal(counterfactuals: List[Counterfactual]) -> str:
|
|
111
|
+
"""Format counterfactuals for terminal display."""
|
|
112
|
+
if not counterfactuals:
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
lines = ["", " What dotscope prevented:"]
|
|
116
|
+
for cf in counterfactuals:
|
|
117
|
+
lines.append(f" {cf.description}")
|
|
118
|
+
lines.append(f" <- {cf.source}")
|
|
119
|
+
return "\n".join(lines)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Internals
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def _extract_served_contracts(constraints: List[dict]) -> List[dict]:
|
|
127
|
+
"""Extract contract constraints from what was served."""
|
|
128
|
+
return [c for c in constraints if c.get("category") == "contract"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _extract_served_intents(constraints: List[dict]) -> List[dict]:
|
|
132
|
+
"""Extract intent constraints from what was served."""
|
|
133
|
+
return [c for c in constraints if c.get("category") == "intent"]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _contract_was_served(
|
|
137
|
+
trigger: str, coupled: str, served: List[dict]
|
|
138
|
+
) -> bool:
|
|
139
|
+
"""Check if a specific contract was in the served constraints."""
|
|
140
|
+
for c in served:
|
|
141
|
+
msg = c.get("message", "")
|
|
142
|
+
if trigger in msg and coupled in msg:
|
|
143
|
+
return True
|
|
144
|
+
if coupled in msg and trigger in msg:
|
|
145
|
+
return True
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _intent_was_served(
|
|
150
|
+
directive: str, modules: List[str], served: List[dict]
|
|
151
|
+
) -> bool:
|
|
152
|
+
"""Check if a specific intent was in the served constraints."""
|
|
153
|
+
for c in served:
|
|
154
|
+
msg = c.get("message", "")
|
|
155
|
+
if directive in msg and any(m.rstrip("/") in msg for m in modules):
|
|
156
|
+
return True
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _has_new_coupling(modules: List[str], diff_text: str) -> bool:
|
|
161
|
+
"""Check if the diff introduces new imports between the listed modules."""
|
|
162
|
+
import_re = re.compile(r'(?:from\s+(\S+)\s+import|import\s+(\S+))')
|
|
163
|
+
current_file = ""
|
|
164
|
+
|
|
165
|
+
for line in diff_text.splitlines():
|
|
166
|
+
if line.startswith("diff --git"):
|
|
167
|
+
parts = line.split(" b/", 1)
|
|
168
|
+
current_file = parts[1] if len(parts) > 1 else ""
|
|
169
|
+
elif line.startswith("+") and not line.startswith("+++"):
|
|
170
|
+
m = import_re.search(line)
|
|
171
|
+
if not m:
|
|
172
|
+
continue
|
|
173
|
+
imported = (m.group(1) or m.group(2) or "").split(".")[0]
|
|
174
|
+
imported_mod = imported + "/"
|
|
175
|
+
|
|
176
|
+
file_mod = current_file.split("/")[0] + "/" if "/" in current_file else ""
|
|
177
|
+
if file_mod in modules and imported_mod in modules and file_mod != imported_mod:
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
return False
|
dotscope/debug.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Context bisection: debug why an agent session produced a bad outcome.
|
|
2
|
+
|
|
3
|
+
Deterministic. No LLM calls. Bisects files, context sections, and
|
|
4
|
+
constraints to identify the root cause.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from typing import Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from .models.state import BisectionResult # noqa: F401
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def debug_session(
|
|
15
|
+
session_id: str,
|
|
16
|
+
repo_root: str,
|
|
17
|
+
) -> Optional[BisectionResult]:
|
|
18
|
+
"""Bisect a session to find root cause of bad outcome."""
|
|
19
|
+
session = _load_session(repo_root, session_id)
|
|
20
|
+
observation = _load_observation(repo_root, session_id)
|
|
21
|
+
|
|
22
|
+
if not session or not observation:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
recall = observation.get("recall", 1.0)
|
|
26
|
+
if recall >= 0.8:
|
|
27
|
+
return None # Nothing to debug
|
|
28
|
+
|
|
29
|
+
resolved_files = set(session.get("predicted_files", []))
|
|
30
|
+
actual_files = set(observation.get("actual_files_modified", []))
|
|
31
|
+
|
|
32
|
+
# Bisect files
|
|
33
|
+
files_that_mattered = sorted(resolved_files & actual_files)
|
|
34
|
+
files_that_didnt_help = sorted(resolved_files - actual_files)
|
|
35
|
+
missing_files = sorted(actual_files - resolved_files)
|
|
36
|
+
|
|
37
|
+
# Bisect context
|
|
38
|
+
context = session.get("context", "")
|
|
39
|
+
sections = _parse_sections(context)
|
|
40
|
+
relevant = []
|
|
41
|
+
irrelevant = []
|
|
42
|
+
for name, text in sections.items():
|
|
43
|
+
if any(f in text or os.path.basename(f) in text for f in actual_files):
|
|
44
|
+
relevant.append(name)
|
|
45
|
+
else:
|
|
46
|
+
irrelevant.append(name)
|
|
47
|
+
|
|
48
|
+
# Bisect constraints
|
|
49
|
+
constraints = session.get("constraints_served", [])
|
|
50
|
+
honored = []
|
|
51
|
+
violated = []
|
|
52
|
+
for c in constraints:
|
|
53
|
+
if _constraint_violated(c, observation):
|
|
54
|
+
violated.append(c)
|
|
55
|
+
else:
|
|
56
|
+
honored.append(c)
|
|
57
|
+
|
|
58
|
+
# Diagnose
|
|
59
|
+
diagnosis, recommendations = _diagnose(
|
|
60
|
+
missing_files, violated, files_that_didnt_help,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return BisectionResult(
|
|
64
|
+
session_id=session_id,
|
|
65
|
+
files_that_mattered=files_that_mattered,
|
|
66
|
+
files_that_didnt_help=files_that_didnt_help,
|
|
67
|
+
context_sections_relevant=relevant,
|
|
68
|
+
context_sections_irrelevant=irrelevant,
|
|
69
|
+
constraints_honored=honored,
|
|
70
|
+
constraints_violated=violated,
|
|
71
|
+
missing_files=missing_files,
|
|
72
|
+
diagnosis=diagnosis,
|
|
73
|
+
recommendations=recommendations,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def list_bad_sessions(repo_root: str, limit: int = 10) -> List[dict]:
|
|
78
|
+
"""List sessions with low recall for debugging."""
|
|
79
|
+
from .sessions import SessionManager
|
|
80
|
+
mgr = SessionManager(repo_root)
|
|
81
|
+
observations = mgr.get_observations(limit=200)
|
|
82
|
+
sessions = mgr.get_sessions(limit=200)
|
|
83
|
+
session_map = {s.session_id: s for s in sessions}
|
|
84
|
+
|
|
85
|
+
bad = []
|
|
86
|
+
for obs in observations:
|
|
87
|
+
if obs.recall < 0.8:
|
|
88
|
+
s = session_map.get(obs.session_id)
|
|
89
|
+
bad.append({
|
|
90
|
+
"session_id": obs.session_id,
|
|
91
|
+
"scope": s.scope_expr if s else "unknown",
|
|
92
|
+
"recall": obs.recall,
|
|
93
|
+
"gaps": obs.touched_not_predicted[:3],
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return bad[:limit]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def format_debug_report(result: BisectionResult) -> str:
|
|
100
|
+
"""Format bisection result for terminal output."""
|
|
101
|
+
lines = [f"dotscope debug: session {result.session_id}\n"]
|
|
102
|
+
|
|
103
|
+
lines.append(" File Bisection")
|
|
104
|
+
if result.files_that_mattered:
|
|
105
|
+
lines.append(f" Files that mattered: {', '.join(result.files_that_mattered)}")
|
|
106
|
+
if result.files_that_didnt_help:
|
|
107
|
+
lines.append(f" Files that didn't help: {', '.join(result.files_that_didnt_help)}")
|
|
108
|
+
if result.missing_files:
|
|
109
|
+
lines.append(f" Missing files: {', '.join(result.missing_files)}")
|
|
110
|
+
lines.append("")
|
|
111
|
+
|
|
112
|
+
if result.context_sections_relevant or result.context_sections_irrelevant:
|
|
113
|
+
lines.append(" Context Bisection")
|
|
114
|
+
for s in result.context_sections_relevant:
|
|
115
|
+
lines.append(f" Relevant: {s}")
|
|
116
|
+
for s in result.context_sections_irrelevant:
|
|
117
|
+
lines.append(f" Irrelevant: {s}")
|
|
118
|
+
lines.append("")
|
|
119
|
+
|
|
120
|
+
if result.constraints_violated:
|
|
121
|
+
lines.append(" Constraints Violated")
|
|
122
|
+
for c in result.constraints_violated:
|
|
123
|
+
lines.append(f" {c.get('message', 'unknown')}")
|
|
124
|
+
lines.append("")
|
|
125
|
+
|
|
126
|
+
lines.append(f" Diagnosis: {result.diagnosis}")
|
|
127
|
+
for rec in result.recommendations:
|
|
128
|
+
lines.append(f" -> {rec}")
|
|
129
|
+
|
|
130
|
+
return "\n".join(lines)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# Internals
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def _load_session(repo_root: str, session_id: str) -> Optional[dict]:
|
|
138
|
+
"""Load a session file."""
|
|
139
|
+
path = os.path.join(repo_root, ".dotscope", "sessions", f"{session_id}.json")
|
|
140
|
+
if not os.path.exists(path):
|
|
141
|
+
return None
|
|
142
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
143
|
+
return json.load(f)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _load_observation(repo_root: str, session_id: str) -> Optional[dict]:
|
|
147
|
+
"""Find observation matching a session."""
|
|
148
|
+
from .sessions import SessionManager
|
|
149
|
+
mgr = SessionManager(repo_root)
|
|
150
|
+
for obs in mgr.get_observations(limit=200):
|
|
151
|
+
if obs.session_id == session_id:
|
|
152
|
+
return {
|
|
153
|
+
"actual_files_modified": obs.actual_files_modified,
|
|
154
|
+
"recall": obs.recall,
|
|
155
|
+
"touched_not_predicted": obs.touched_not_predicted,
|
|
156
|
+
}
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_sections(context: str) -> Dict[str, str]:
|
|
161
|
+
"""Parse context into named sections (## headers)."""
|
|
162
|
+
sections: Dict[str, str] = {}
|
|
163
|
+
current = "main"
|
|
164
|
+
lines: List[str] = []
|
|
165
|
+
|
|
166
|
+
for line in context.splitlines():
|
|
167
|
+
if line.startswith("## "):
|
|
168
|
+
if lines:
|
|
169
|
+
sections[current] = "\n".join(lines)
|
|
170
|
+
current = line[3:].strip()
|
|
171
|
+
lines = []
|
|
172
|
+
else:
|
|
173
|
+
lines.append(line)
|
|
174
|
+
|
|
175
|
+
if lines:
|
|
176
|
+
sections[current] = "\n".join(lines)
|
|
177
|
+
return sections
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _constraint_violated(constraint: dict, observation: dict) -> bool:
|
|
181
|
+
"""Check if a served constraint was violated in the observation."""
|
|
182
|
+
msg = constraint.get("message", "").lower()
|
|
183
|
+
actual = set(observation.get("actual_files_modified", []))
|
|
184
|
+
|
|
185
|
+
# Contract: check if both sides were modified
|
|
186
|
+
if "modify" in msg and "review" in msg:
|
|
187
|
+
for f in actual:
|
|
188
|
+
if f.lower() in msg:
|
|
189
|
+
# One side modified — check if the other was too
|
|
190
|
+
return True # Simplified: if contract mentioned, check presence
|
|
191
|
+
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _diagnose(
|
|
196
|
+
missing_files: List[str],
|
|
197
|
+
constraints_violated: List[dict],
|
|
198
|
+
files_unused: List[str],
|
|
199
|
+
) -> Tuple[str, List[str]]:
|
|
200
|
+
"""Determine root cause and recommendations."""
|
|
201
|
+
recommendations = []
|
|
202
|
+
|
|
203
|
+
if missing_files:
|
|
204
|
+
for f in missing_files[:3]:
|
|
205
|
+
recommendations.append(f"Add {f} to scope includes or add assertion ensure_includes: [{f}]")
|
|
206
|
+
return "resolution_gap", recommendations
|
|
207
|
+
|
|
208
|
+
if constraints_violated and not missing_files:
|
|
209
|
+
for c in constraints_violated:
|
|
210
|
+
recommendations.append(
|
|
211
|
+
f"Agent ignored constraint: {c.get('message', '')[:60]}. "
|
|
212
|
+
f"Consider strengthening to HOLD."
|
|
213
|
+
)
|
|
214
|
+
return "agent_ignored", recommendations
|
|
215
|
+
|
|
216
|
+
if not missing_files and not constraints_violated:
|
|
217
|
+
recommendations.append("Add anti-patterns or intent directives for the patterns that caused the bad commit")
|
|
218
|
+
return "constraint_gap", recommendations
|
|
219
|
+
|
|
220
|
+
return "context_conflict", ["Review scope context for contradictory guidance"]
|