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/storage/.scope
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
description: Infrastructure — disk I/O, hooks, caching, state persistence
|
|
2
|
+
includes:
|
|
3
|
+
- session_manager.py
|
|
4
|
+
- cache.py
|
|
5
|
+
- git_hooks.py
|
|
6
|
+
- claude_hooks.py
|
|
7
|
+
- onboarding.py
|
|
8
|
+
- timing.py
|
|
9
|
+
- near_miss.py
|
|
10
|
+
- incremental_state.py
|
|
11
|
+
excludes:
|
|
12
|
+
- __pycache__/
|
|
13
|
+
context: |
|
|
14
|
+
Storage reads and writes models.state to disk. Pure infrastructure.
|
|
15
|
+
|
|
16
|
+
## session_manager.py
|
|
17
|
+
SessionManager class. Creates sessions (.dotscope/sessions/),
|
|
18
|
+
records observations (.dotscope/observations/). Matches commits
|
|
19
|
+
to sessions by timestamp overlap.
|
|
20
|
+
|
|
21
|
+
## cache.py
|
|
22
|
+
Caches history analysis and graph hubs after ingest so the MCP
|
|
23
|
+
server can load them at startup without re-computing.
|
|
24
|
+
|
|
25
|
+
## git_hooks.py
|
|
26
|
+
Two git hooks installed by `dotscope hook install`:
|
|
27
|
+
pre-commit: runs `dotscope check`, blocks on HOLDs (enforcement)
|
|
28
|
+
post-commit: runs `dotscope observe` + `dotscope incremental` (feedback)
|
|
29
|
+
Cross-platform: POSIX shell script or Python fallback for Windows.
|
|
30
|
+
Marks hook_installed in onboarding state.
|
|
31
|
+
|
|
32
|
+
## claude_hooks.py
|
|
33
|
+
Claude Code PreToolUse hook installed by `dotscope hook claude`.
|
|
34
|
+
Writes .claude/hooks/pre-commit-check.sh and wires it into
|
|
35
|
+
.claude/settings.json. Defense-in-depth layer for Claude Code.
|
|
36
|
+
|
|
37
|
+
## onboarding.py
|
|
38
|
+
Milestone tracking in .dotscope/onboarding.json. Gating rules:
|
|
39
|
+
counterfactuals after 3+ observations, health nudges after 7+ days.
|
|
40
|
+
Next-step prompts: ingest -> backtest -> conventions -> voice -> MCP -> hook -> stop.
|
|
41
|
+
|
|
42
|
+
## timing.py
|
|
43
|
+
Appends operation timing (resolve, check, ingest) to timings.jsonl.
|
|
44
|
+
Used by dotscope bench.
|
|
45
|
+
|
|
46
|
+
## near_miss.py
|
|
47
|
+
Persistent storage for near-miss detections and session scopes.
|
|
48
|
+
|
|
49
|
+
## incremental_state.py
|
|
50
|
+
Tracks commits_since_last_full_ingest in .dotscope/incremental.json.
|
|
51
|
+
Reset after full ingest. Used to prompt for re-scan after 200 commits.
|
|
52
|
+
|
|
53
|
+
## Gotchas
|
|
54
|
+
All file I/O uses encoding="utf-8" for Windows compatibility.
|
|
55
|
+
Session files are JSON, observation files are JSONL (append-only).
|
|
56
|
+
.dotscope/ is gitignored and fully rebuildable via `dotscope rebuild`.
|
|
57
|
+
related:
|
|
58
|
+
- dotscope/models/.scope
|
|
59
|
+
tags:
|
|
60
|
+
- storage
|
|
61
|
+
- sessions
|
|
62
|
+
- hooks
|
|
63
|
+
- caching
|
|
64
|
+
tokens_estimate: 3800
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Storage layer: persistent state management."""
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Cache structured ingest data to .dotscope/ for MCP server consumption.
|
|
2
|
+
|
|
3
|
+
Serializes HistoryAnalysis and DependencyGraph to JSON after ingest.
|
|
4
|
+
MCP server loads them on startup for attribution hints and near-miss detection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from ..graph import DependencyGraph, FileNode, ModuleBoundary
|
|
14
|
+
from ..history import (
|
|
15
|
+
HistoryAnalysis, FileHistory, ChangeCoupling, ImplicitContract,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cache_ingest_data(
|
|
20
|
+
root: str,
|
|
21
|
+
history: Optional[HistoryAnalysis] = None,
|
|
22
|
+
graph: Optional[DependencyGraph] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Cache history and graph to .dotscope/ after ingest."""
|
|
25
|
+
dot_dir = Path(root) / ".dotscope"
|
|
26
|
+
dot_dir.mkdir(exist_ok=True)
|
|
27
|
+
|
|
28
|
+
if history and history.commits_analyzed > 0:
|
|
29
|
+
data = {
|
|
30
|
+
"commits_analyzed": history.commits_analyzed,
|
|
31
|
+
"implicit_contracts": [
|
|
32
|
+
{
|
|
33
|
+
"trigger_file": ic.trigger_file,
|
|
34
|
+
"coupled_file": ic.coupled_file,
|
|
35
|
+
"confidence": ic.confidence,
|
|
36
|
+
"occurrences": ic.occurrences,
|
|
37
|
+
"description": ic.description,
|
|
38
|
+
}
|
|
39
|
+
for ic in history.implicit_contracts
|
|
40
|
+
],
|
|
41
|
+
"hotspots": history.hotspots[:20],
|
|
42
|
+
"file_stabilities": {
|
|
43
|
+
path: {"stability": fh.stability, "commit_count": fh.commit_count}
|
|
44
|
+
for path, fh in history.file_histories.items()
|
|
45
|
+
if fh.stability
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
with open(dot_dir / "history.json", "w", encoding="utf-8") as f:
|
|
49
|
+
json.dump(data, f, indent=2)
|
|
50
|
+
|
|
51
|
+
if graph and graph.files:
|
|
52
|
+
# Only cache what attribution hints need: imported_by fan-in
|
|
53
|
+
hubs = {}
|
|
54
|
+
for path, node in graph.files.items():
|
|
55
|
+
if len(node.imported_by) >= 3:
|
|
56
|
+
hubs[path] = {
|
|
57
|
+
"imported_by_count": len(node.imported_by),
|
|
58
|
+
"imported_by_dirs": sorted(set(
|
|
59
|
+
str(Path(p).parts[0]) for p in node.imported_by
|
|
60
|
+
if "/" in p
|
|
61
|
+
)),
|
|
62
|
+
}
|
|
63
|
+
if hubs:
|
|
64
|
+
with open(dot_dir / "graph_hubs.json", "w", encoding="utf-8") as f:
|
|
65
|
+
json.dump(hubs, f, indent=2)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_cached_history(root: str) -> Optional[HistoryAnalysis]:
|
|
69
|
+
"""Load cached history from ..dotscope/history.json."""
|
|
70
|
+
path = Path(root) / ".dotscope" / "history.json"
|
|
71
|
+
if not path.exists():
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
76
|
+
data = json.load(f)
|
|
77
|
+
|
|
78
|
+
history = HistoryAnalysis(
|
|
79
|
+
commits_analyzed=data.get("commits_analyzed", 0),
|
|
80
|
+
)
|
|
81
|
+
for ic_data in data.get("implicit_contracts", []):
|
|
82
|
+
history.implicit_contracts.append(ImplicitContract(
|
|
83
|
+
trigger_file=ic_data["trigger_file"],
|
|
84
|
+
coupled_file=ic_data["coupled_file"],
|
|
85
|
+
confidence=ic_data["confidence"],
|
|
86
|
+
occurrences=ic_data["occurrences"],
|
|
87
|
+
description=ic_data.get("description", ""),
|
|
88
|
+
))
|
|
89
|
+
for path_str, fh_data in data.get("file_stabilities", {}).items():
|
|
90
|
+
history.file_histories[path_str] = FileHistory(
|
|
91
|
+
path=path_str,
|
|
92
|
+
stability=fh_data.get("stability", ""),
|
|
93
|
+
commit_count=fh_data.get("commit_count", 0),
|
|
94
|
+
)
|
|
95
|
+
history.hotspots = data.get("hotspots", [])
|
|
96
|
+
return history
|
|
97
|
+
except (json.JSONDecodeError, KeyError):
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_cached_graph_hubs(root: str) -> dict:
|
|
102
|
+
"""Load cached graph hub data from ..dotscope/graph_hubs.json.
|
|
103
|
+
|
|
104
|
+
Returns: {path: {"imported_by_count": int, "imported_by_dirs": [str]}}
|
|
105
|
+
"""
|
|
106
|
+
path = Path(root) / ".dotscope" / "graph_hubs.json"
|
|
107
|
+
if not path.exists():
|
|
108
|
+
return {}
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
112
|
+
return json.load(f)
|
|
113
|
+
except (json.JSONDecodeError, KeyError):
|
|
114
|
+
return {}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Claude Code hook installation for automatic pre-commit routing verification.
|
|
2
|
+
|
|
3
|
+
Writes a PreToolUse hook that intercepts git commit commands and runs
|
|
4
|
+
dotscope check. GUARDs block the commit (exit 2). NUDGEs and NOTEs pass through.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import stat
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_HOOK_SCRIPT = """\
|
|
13
|
+
#!/bin/bash
|
|
14
|
+
# dotscope pre-commit enforcement for Claude Code
|
|
15
|
+
#
|
|
16
|
+
# Intercepts git commit commands and runs dotscope check on staged changes.
|
|
17
|
+
# Exit 2 blocks the commit and feeds the error back to the agent.
|
|
18
|
+
# Non-commit Bash commands pass through untouched.
|
|
19
|
+
|
|
20
|
+
set -e
|
|
21
|
+
|
|
22
|
+
INPUT=$(cat)
|
|
23
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
24
|
+
|
|
25
|
+
# Only intercept git commit commands
|
|
26
|
+
case "$COMMAND" in
|
|
27
|
+
git\\ commit*) ;;
|
|
28
|
+
*) exit 0 ;;
|
|
29
|
+
esac
|
|
30
|
+
|
|
31
|
+
# Run dotscope check on staged changes (30s timeout, fail open)
|
|
32
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
33
|
+
OUTPUT=$(timeout 30 python3 -m dotscope.cli check 2>&1) || true
|
|
34
|
+
elif command -v gtimeout >/dev/null 2>&1; then
|
|
35
|
+
OUTPUT=$(gtimeout 30 python3 -m dotscope.cli check 2>&1) || true
|
|
36
|
+
else
|
|
37
|
+
OUTPUT=$(python3 -m dotscope.cli check 2>&1) || true
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# Only GUARDs block. NUDGEs and NOTEs pass through.
|
|
41
|
+
if echo "$OUTPUT" | grep -qE "GUARD|HOLD"; then
|
|
42
|
+
echo "$OUTPUT" >&2
|
|
43
|
+
echo "" >&2
|
|
44
|
+
echo "dotscope: commit blocked -- address guards before committing" >&2
|
|
45
|
+
exit 2
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# NUDGEs and NOTEs are guidance, not gates
|
|
49
|
+
if echo "$OUTPUT" | grep -qE "NUDGE|NOTE"; then
|
|
50
|
+
echo "$OUTPUT" >&2
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
exit 0
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def install_claude_hook(repo_root: str) -> str:
|
|
58
|
+
"""Install Claude Code pre-commit enforcement hook.
|
|
59
|
+
|
|
60
|
+
Creates .claude/hooks/pre-commit-check.sh and wires it into
|
|
61
|
+
.claude/settings.json as a PreToolUse hook on Bash commands.
|
|
62
|
+
|
|
63
|
+
Preserves existing settings.json content (permissions, other hooks).
|
|
64
|
+
"""
|
|
65
|
+
claude_dir = os.path.join(repo_root, ".claude")
|
|
66
|
+
hooks_dir = os.path.join(claude_dir, "hooks")
|
|
67
|
+
os.makedirs(hooks_dir, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
# Write the hook script
|
|
70
|
+
script_path = os.path.join(hooks_dir, "pre-commit-check.sh")
|
|
71
|
+
with open(script_path, "w", encoding="utf-8") as f:
|
|
72
|
+
f.write(_HOOK_SCRIPT)
|
|
73
|
+
try:
|
|
74
|
+
os.chmod(script_path, os.stat(script_path).st_mode | stat.S_IEXEC)
|
|
75
|
+
except OSError:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# Load or create settings.json
|
|
79
|
+
settings_path = os.path.join(claude_dir, "settings.json")
|
|
80
|
+
settings = {}
|
|
81
|
+
if os.path.exists(settings_path):
|
|
82
|
+
try:
|
|
83
|
+
with open(settings_path, "r", encoding="utf-8") as f:
|
|
84
|
+
settings = json.load(f)
|
|
85
|
+
except (json.JSONDecodeError, IOError):
|
|
86
|
+
settings = {}
|
|
87
|
+
|
|
88
|
+
# Add the hook (idempotent)
|
|
89
|
+
hooks = settings.setdefault("hooks", {})
|
|
90
|
+
pre_tool = hooks.setdefault("PreToolUse", [])
|
|
91
|
+
|
|
92
|
+
# Check if already installed
|
|
93
|
+
hook_entry = {
|
|
94
|
+
"matcher": "Bash",
|
|
95
|
+
"hooks": [
|
|
96
|
+
{
|
|
97
|
+
"type": "command",
|
|
98
|
+
"command": ".claude/hooks/pre-commit-check.sh",
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
already = any(
|
|
104
|
+
entry.get("matcher") == "Bash"
|
|
105
|
+
and any(
|
|
106
|
+
h.get("command", "").endswith("pre-commit-check.sh")
|
|
107
|
+
for h in entry.get("hooks", [])
|
|
108
|
+
)
|
|
109
|
+
for entry in pre_tool
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if not already:
|
|
113
|
+
pre_tool.append(hook_entry)
|
|
114
|
+
|
|
115
|
+
with open(settings_path, "w", encoding="utf-8") as f:
|
|
116
|
+
json.dump(settings, f, indent=2)
|
|
117
|
+
f.write("\n")
|
|
118
|
+
|
|
119
|
+
return f"Claude Code hook installed: {script_path}"
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Git hook management for dotscope.
|
|
2
|
+
|
|
3
|
+
Two hooks:
|
|
4
|
+
pre-commit — routing verification. Runs dotscope check, blocks on GUARDs only.
|
|
5
|
+
post-commit — feedback loop. Runs observe + incremental, never blocks.
|
|
6
|
+
|
|
7
|
+
Both are deliberately minimal. All logic lives in Python.
|
|
8
|
+
|
|
9
|
+
On Windows (no /bin/sh), we write Python-based hooks instead.
|
|
10
|
+
Git for Windows invokes extensionless hook files via its bundled sh,
|
|
11
|
+
but we also handle the pure-Windows case.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import stat
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_POST_MARKER = "# dotscope auto-observer"
|
|
20
|
+
_INCREMENTAL_MARKER = "# dotscope incremental"
|
|
21
|
+
_PRE_MARKER = "# dotscope pre-commit"
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Pre-commit hook: routing verification (blocks on GUARDs only)
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
_PRE_COMMIT_SH = """\
|
|
28
|
+
#!/bin/sh
|
|
29
|
+
# dotscope pre-commit
|
|
30
|
+
# Runs dotscope check on staged changes. Only GUARDs block the commit.
|
|
31
|
+
# NUDGEs and NOTEs print but pass through.
|
|
32
|
+
# Timeout after 30 seconds — fail open if dotscope hangs.
|
|
33
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
34
|
+
OUTPUT=$(timeout 30 python3 -m dotscope.cli check 2>&1) || true
|
|
35
|
+
elif command -v gtimeout >/dev/null 2>&1; then
|
|
36
|
+
OUTPUT=$(gtimeout 30 python3 -m dotscope.cli check 2>&1) || true
|
|
37
|
+
else
|
|
38
|
+
OUTPUT=$(python3 -m dotscope.cli check 2>&1) || true
|
|
39
|
+
fi
|
|
40
|
+
if echo "$OUTPUT" | grep -qE "GUARD|HOLD"; then
|
|
41
|
+
echo "$OUTPUT" >&2
|
|
42
|
+
echo "" >&2
|
|
43
|
+
echo "dotscope: commit blocked -- address guards before committing" >&2
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
if echo "$OUTPUT" | grep -qE "NUDGE|NOTE"; then
|
|
47
|
+
echo "$OUTPUT" >&2
|
|
48
|
+
fi
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_PRE_COMMIT_PY = """\
|
|
52
|
+
#!/usr/bin/env python3
|
|
53
|
+
# dotscope pre-commit
|
|
54
|
+
import subprocess, sys
|
|
55
|
+
try:
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
[sys.executable, "-m", "dotscope.cli", "check"],
|
|
58
|
+
capture_output=True, text=True, timeout=30,
|
|
59
|
+
)
|
|
60
|
+
output = result.stdout + result.stderr
|
|
61
|
+
if "GUARD" in output or "HOLD" in output:
|
|
62
|
+
print(output, file=sys.stderr)
|
|
63
|
+
print("", file=sys.stderr)
|
|
64
|
+
print("dotscope: commit blocked -- address guards before committing", file=sys.stderr)
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
if "NUDGE" in output or "NOTE" in output:
|
|
67
|
+
print(output, file=sys.stderr)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass # If dotscope fails, don't block
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Post-commit hook: feedback loop (never blocks)
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
_POST_COMMIT_SH = """\
|
|
77
|
+
#!/bin/sh
|
|
78
|
+
# dotscope auto-observer
|
|
79
|
+
COMMIT_HASH=$(git rev-parse HEAD)
|
|
80
|
+
# Capture observation output for agent feedback (Gap 4)
|
|
81
|
+
OUTPUT=$(python3 -m dotscope.cli observe "$COMMIT_HASH" 2>&1) || true
|
|
82
|
+
if [ -n "$OUTPUT" ]; then
|
|
83
|
+
echo "$OUTPUT" >&2
|
|
84
|
+
fi
|
|
85
|
+
# dotscope incremental
|
|
86
|
+
python3 -m dotscope.cli incremental "$COMMIT_HASH" 2>/dev/null || true
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
_POST_COMMIT_PY = """\
|
|
90
|
+
#!/usr/bin/env python3
|
|
91
|
+
# dotscope auto-observer
|
|
92
|
+
import subprocess, sys
|
|
93
|
+
try:
|
|
94
|
+
result = subprocess.run(["git", "rev-parse", "HEAD"],
|
|
95
|
+
capture_output=True, text=True, timeout=10)
|
|
96
|
+
if result.returncode == 0:
|
|
97
|
+
commit = result.stdout.strip()
|
|
98
|
+
subprocess.run([sys.executable, "-m", "dotscope.cli", "observe", commit],
|
|
99
|
+
timeout=30, capture_output=True)
|
|
100
|
+
# dotscope incremental
|
|
101
|
+
subprocess.run([sys.executable, "-m", "dotscope.cli", "incremental", commit],
|
|
102
|
+
timeout=30, capture_output=True)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass # Never block commits
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_windows_native() -> bool:
|
|
109
|
+
"""True if running on Windows without Git Bash / MSYS2 / WSL."""
|
|
110
|
+
if os.name != "nt":
|
|
111
|
+
return False
|
|
112
|
+
return not (os.environ.get("MSYSTEM") or os.environ.get("SHELL"))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _write_hook(hook_path: Path, content: str, marker: str) -> str:
|
|
116
|
+
"""Write or append a hook file. Returns the path as string."""
|
|
117
|
+
hook_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
if hook_path.exists():
|
|
120
|
+
existing = hook_path.read_text(encoding="utf-8")
|
|
121
|
+
if marker in existing:
|
|
122
|
+
return str(hook_path) # Already installed
|
|
123
|
+
# Append to existing hook
|
|
124
|
+
with open(hook_path, "a", encoding="utf-8") as f:
|
|
125
|
+
f.write(f"\n{content}")
|
|
126
|
+
else:
|
|
127
|
+
hook_path.write_text(content, encoding="utf-8")
|
|
128
|
+
|
|
129
|
+
# Make executable
|
|
130
|
+
try:
|
|
131
|
+
hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
return str(hook_path)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _remove_hook_lines(hook_path: Path, marker: str) -> bool:
|
|
139
|
+
"""Remove lines associated with a marker from a hook file."""
|
|
140
|
+
if not hook_path.exists():
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
content = hook_path.read_text(encoding="utf-8")
|
|
144
|
+
if marker not in content:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
lines = content.splitlines()
|
|
148
|
+
filtered = []
|
|
149
|
+
in_block = False
|
|
150
|
+
for line in lines:
|
|
151
|
+
if marker in line:
|
|
152
|
+
in_block = True
|
|
153
|
+
continue
|
|
154
|
+
if in_block and ("dotscope" in line or "COMMIT_HASH" in line
|
|
155
|
+
or "subprocess" in line or "OUTPUT" in line
|
|
156
|
+
or line.strip().startswith("if ") or line.strip().startswith("echo ")
|
|
157
|
+
or line.strip().startswith("exit ") or line.strip().startswith("fi")
|
|
158
|
+
or line.strip().startswith("print(") or line.strip().startswith("sys.exit")
|
|
159
|
+
or line.strip().startswith("result =") or line.strip().startswith("output =")
|
|
160
|
+
or line.strip().startswith("except") or line.strip().startswith("try:")
|
|
161
|
+
or line.strip().startswith("pass")):
|
|
162
|
+
continue
|
|
163
|
+
in_block = False
|
|
164
|
+
filtered.append(line)
|
|
165
|
+
|
|
166
|
+
remaining = "\n".join(filtered).strip()
|
|
167
|
+
shebang_only = remaining in ("#!/bin/sh", "#!/usr/bin/env python3")
|
|
168
|
+
if not remaining or shebang_only:
|
|
169
|
+
hook_path.unlink()
|
|
170
|
+
else:
|
|
171
|
+
hook_path.write_text(remaining + "\n", encoding="utf-8")
|
|
172
|
+
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Public API
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def install_hook(repo_root: str) -> str:
|
|
181
|
+
"""Install both pre-commit and post-commit hooks. Returns summary."""
|
|
182
|
+
hooks_dir = Path(repo_root) / ".git" / "hooks"
|
|
183
|
+
results = []
|
|
184
|
+
|
|
185
|
+
# Pre-commit (enforcement)
|
|
186
|
+
pre_path = hooks_dir / "pre-commit"
|
|
187
|
+
if _is_windows_native():
|
|
188
|
+
pre_content = _PRE_COMMIT_PY
|
|
189
|
+
else:
|
|
190
|
+
pre_content = _PRE_COMMIT_SH
|
|
191
|
+
path = _write_hook(pre_path, pre_content, _PRE_MARKER)
|
|
192
|
+
results.append(f"pre-commit: {path}")
|
|
193
|
+
|
|
194
|
+
# Post-commit (feedback loop)
|
|
195
|
+
post_path = hooks_dir / "post-commit"
|
|
196
|
+
if _is_windows_native():
|
|
197
|
+
post_content = _POST_COMMIT_PY
|
|
198
|
+
else:
|
|
199
|
+
post_content = _POST_COMMIT_SH
|
|
200
|
+
|
|
201
|
+
if post_path.exists():
|
|
202
|
+
existing = post_path.read_text(encoding="utf-8")
|
|
203
|
+
if _POST_MARKER in existing:
|
|
204
|
+
# Upgrade: add incremental line if missing
|
|
205
|
+
if _INCREMENTAL_MARKER not in existing:
|
|
206
|
+
incremental_line = (
|
|
207
|
+
'# dotscope incremental\n'
|
|
208
|
+
'python3 -m dotscope.cli incremental "$COMMIT_HASH" 2>/dev/null || true\n'
|
|
209
|
+
)
|
|
210
|
+
with open(post_path, "a", encoding="utf-8") as f:
|
|
211
|
+
f.write(f"\n{incremental_line}")
|
|
212
|
+
results.append(f"post-commit: {post_path} (existing, upgraded)")
|
|
213
|
+
else:
|
|
214
|
+
path = _write_hook(post_path, post_content, _POST_MARKER)
|
|
215
|
+
results.append(f"post-commit: {path}")
|
|
216
|
+
else:
|
|
217
|
+
path = _write_hook(post_path, post_content, _POST_MARKER)
|
|
218
|
+
results.append(f"post-commit: {path}")
|
|
219
|
+
|
|
220
|
+
# Onboarding
|
|
221
|
+
try:
|
|
222
|
+
from .onboarding import mark_milestone
|
|
223
|
+
mark_milestone(repo_root, "hook_installed")
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
return "\n".join(results)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def uninstall_hook(repo_root: str) -> bool:
|
|
231
|
+
"""Remove dotscope from both hook files. Returns True if anything removed."""
|
|
232
|
+
hooks_dir = Path(repo_root) / ".git" / "hooks"
|
|
233
|
+
removed = False
|
|
234
|
+
|
|
235
|
+
removed |= _remove_hook_lines(hooks_dir / "pre-commit", _PRE_MARKER)
|
|
236
|
+
removed |= _remove_hook_lines(hooks_dir / "post-commit", _POST_MARKER)
|
|
237
|
+
removed |= _remove_hook_lines(hooks_dir / "post-commit", _INCREMENTAL_MARKER)
|
|
238
|
+
|
|
239
|
+
return removed
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def is_hook_installed(repo_root: str) -> bool:
|
|
243
|
+
"""Check if dotscope hooks are installed."""
|
|
244
|
+
hooks_dir = Path(repo_root) / ".git" / "hooks"
|
|
245
|
+
|
|
246
|
+
pre_installed = False
|
|
247
|
+
post_installed = False
|
|
248
|
+
|
|
249
|
+
pre_path = hooks_dir / "pre-commit"
|
|
250
|
+
if pre_path.exists():
|
|
251
|
+
pre_installed = _PRE_MARKER in pre_path.read_text(encoding="utf-8")
|
|
252
|
+
|
|
253
|
+
post_path = hooks_dir / "post-commit"
|
|
254
|
+
if post_path.exists():
|
|
255
|
+
post_installed = _POST_MARKER in post_path.read_text(encoding="utf-8")
|
|
256
|
+
|
|
257
|
+
return pre_installed or post_installed
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def hook_status(repo_root: str) -> str:
|
|
261
|
+
"""Return a human-readable hook status."""
|
|
262
|
+
hooks_dir = Path(repo_root) / ".git" / "hooks"
|
|
263
|
+
parts = []
|
|
264
|
+
|
|
265
|
+
pre_path = hooks_dir / "pre-commit"
|
|
266
|
+
if pre_path.exists() and _PRE_MARKER in pre_path.read_text(encoding="utf-8"):
|
|
267
|
+
parts.append("pre-commit: installed (enforcement)")
|
|
268
|
+
else:
|
|
269
|
+
parts.append("pre-commit: not installed")
|
|
270
|
+
|
|
271
|
+
post_path = hooks_dir / "post-commit"
|
|
272
|
+
if post_path.exists() and _POST_MARKER in post_path.read_text(encoding="utf-8"):
|
|
273
|
+
parts.append("post-commit: installed (feedback loop)")
|
|
274
|
+
else:
|
|
275
|
+
parts.append("post-commit: not installed")
|
|
276
|
+
|
|
277
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Persistent state for continuous ingest.
|
|
2
|
+
|
|
3
|
+
Tracks how many commits have passed since the last full ingest,
|
|
4
|
+
so dotscope can prompt for a full re-scan when incremental updates
|
|
5
|
+
have drifted far enough.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Dict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class IncrementalState:
|
|
16
|
+
"""State tracking for continuous ingest."""
|
|
17
|
+
commits_since_last_full_ingest: int = 0
|
|
18
|
+
last_full_ingest_timestamp: str = ""
|
|
19
|
+
last_incremental_commit: str = ""
|
|
20
|
+
uncovered_new_files: int = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_incremental_state(root: str) -> IncrementalState:
|
|
24
|
+
"""Load incremental state from .dotscope/incremental.json."""
|
|
25
|
+
path = os.path.join(root, ".dotscope", "incremental.json")
|
|
26
|
+
if os.path.exists(path):
|
|
27
|
+
try:
|
|
28
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
29
|
+
data = json.load(f)
|
|
30
|
+
return IncrementalState(
|
|
31
|
+
commits_since_last_full_ingest=data.get("commits_since_last_full_ingest", 0),
|
|
32
|
+
last_full_ingest_timestamp=data.get("last_full_ingest_timestamp", ""),
|
|
33
|
+
last_incremental_commit=data.get("last_incremental_commit", ""),
|
|
34
|
+
uncovered_new_files=data.get("uncovered_new_files", 0),
|
|
35
|
+
)
|
|
36
|
+
except (json.JSONDecodeError, IOError):
|
|
37
|
+
pass
|
|
38
|
+
return IncrementalState()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_incremental_state(root: str, state: IncrementalState) -> None:
|
|
42
|
+
"""Persist incremental state to .dotscope/incremental.json."""
|
|
43
|
+
dot_dir = os.path.join(root, ".dotscope")
|
|
44
|
+
os.makedirs(dot_dir, exist_ok=True)
|
|
45
|
+
path = os.path.join(dot_dir, "incremental.json")
|
|
46
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
47
|
+
json.dump({
|
|
48
|
+
"commits_since_last_full_ingest": state.commits_since_last_full_ingest,
|
|
49
|
+
"last_full_ingest_timestamp": state.last_full_ingest_timestamp,
|
|
50
|
+
"last_incremental_commit": state.last_incremental_commit,
|
|
51
|
+
"uncovered_new_files": state.uncovered_new_files,
|
|
52
|
+
}, f, indent=2)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reset_incremental_state(root: str) -> None:
|
|
56
|
+
"""Reset state after a full ingest."""
|
|
57
|
+
import time
|
|
58
|
+
state = IncrementalState(
|
|
59
|
+
last_full_ingest_timestamp=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
60
|
+
)
|
|
61
|
+
save_incremental_state(root, state)
|