dotscope 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. dotscope/.scope +63 -0
  2. dotscope/__init__.py +3 -0
  3. dotscope/absorber.py +390 -0
  4. dotscope/assertions.py +128 -0
  5. dotscope/ast_analyzer.py +2 -0
  6. dotscope/backtest.py +2 -0
  7. dotscope/bench.py +141 -0
  8. dotscope/budget.py +3 -0
  9. dotscope/cache.py +2 -0
  10. dotscope/check/__init__.py +1 -0
  11. dotscope/check/acknowledge.py +2 -0
  12. dotscope/check/checker.py +3 -0
  13. dotscope/check/checks/__init__.py +1 -0
  14. dotscope/check/checks/antipattern.py +2 -0
  15. dotscope/check/checks/boundary.py +2 -0
  16. dotscope/check/checks/contracts.py +3 -0
  17. dotscope/check/checks/direction.py +2 -0
  18. dotscope/check/checks/intent.py +2 -0
  19. dotscope/check/checks/stability.py +2 -0
  20. dotscope/check/constraints.py +2 -0
  21. dotscope/check/models.py +15 -0
  22. dotscope/cli.py +1447 -0
  23. dotscope/composer.py +147 -0
  24. dotscope/constants.py +45 -0
  25. dotscope/context.py +60 -0
  26. dotscope/counterfactual.py +180 -0
  27. dotscope/debug.py +220 -0
  28. dotscope/discovery.py +104 -0
  29. dotscope/formatter.py +157 -0
  30. dotscope/graph.py +3 -0
  31. dotscope/health.py +212 -0
  32. dotscope/help.py +204 -0
  33. dotscope/history.py +6 -0
  34. dotscope/hooks.py +2 -0
  35. dotscope/ingest.py +858 -0
  36. dotscope/intent.py +618 -0
  37. dotscope/lessons.py +223 -0
  38. dotscope/matcher.py +104 -0
  39. dotscope/mcp_server.py +1081 -0
  40. dotscope/models/.scope +45 -0
  41. dotscope/models/__init__.py +7 -0
  42. dotscope/models/core.py +288 -0
  43. dotscope/models/history.py +73 -0
  44. dotscope/models/intent.py +213 -0
  45. dotscope/models/passes.py +58 -0
  46. dotscope/models/state.py +250 -0
  47. dotscope/models.py +9 -0
  48. dotscope/near_miss.py +3 -0
  49. dotscope/onboarding.py +2 -0
  50. dotscope/parser.py +387 -0
  51. dotscope/passes/.scope +105 -0
  52. dotscope/passes/__init__.py +1 -0
  53. dotscope/passes/ast_analyzer.py +508 -0
  54. dotscope/passes/backtest.py +198 -0
  55. dotscope/passes/budget_allocator.py +164 -0
  56. dotscope/passes/convention_compliance.py +40 -0
  57. dotscope/passes/convention_discovery.py +247 -0
  58. dotscope/passes/convention_parser.py +223 -0
  59. dotscope/passes/graph_builder.py +299 -0
  60. dotscope/passes/history_miner.py +336 -0
  61. dotscope/passes/incremental.py +149 -0
  62. dotscope/passes/lang/__init__.py +38 -0
  63. dotscope/passes/lang/_base.py +20 -0
  64. dotscope/passes/lang/_treesitter.py +93 -0
  65. dotscope/passes/lang/go.py +333 -0
  66. dotscope/passes/lang/javascript.py +348 -0
  67. dotscope/passes/lazy.py +152 -0
  68. dotscope/passes/semantic_diff.py +160 -0
  69. dotscope/passes/sentinel/__init__.py +1 -0
  70. dotscope/passes/sentinel/acknowledge.py +222 -0
  71. dotscope/passes/sentinel/checker.py +383 -0
  72. dotscope/passes/sentinel/checks/__init__.py +1 -0
  73. dotscope/passes/sentinel/checks/antipattern.py +84 -0
  74. dotscope/passes/sentinel/checks/boundary.py +46 -0
  75. dotscope/passes/sentinel/checks/contracts.py +148 -0
  76. dotscope/passes/sentinel/checks/convention.py +54 -0
  77. dotscope/passes/sentinel/checks/direction.py +71 -0
  78. dotscope/passes/sentinel/checks/intent.py +207 -0
  79. dotscope/passes/sentinel/checks/stability.py +66 -0
  80. dotscope/passes/sentinel/checks/voice.py +108 -0
  81. dotscope/passes/sentinel/constraints.py +472 -0
  82. dotscope/passes/sentinel/line_filter.py +88 -0
  83. dotscope/passes/sentinel/models.py +15 -0
  84. dotscope/passes/virtual.py +239 -0
  85. dotscope/passes/voice.py +162 -0
  86. dotscope/passes/voice_defaults.py +28 -0
  87. dotscope/passes/voice_discovery.py +245 -0
  88. dotscope/paths.py +32 -0
  89. dotscope/progress.py +44 -0
  90. dotscope/regression.py +147 -0
  91. dotscope/resolver.py +203 -0
  92. dotscope/scanner.py +246 -0
  93. dotscope/sessions.py +2 -0
  94. dotscope/storage/.scope +64 -0
  95. dotscope/storage/__init__.py +1 -0
  96. dotscope/storage/cache.py +114 -0
  97. dotscope/storage/claude_hooks.py +119 -0
  98. dotscope/storage/git_hooks.py +277 -0
  99. dotscope/storage/incremental_state.py +61 -0
  100. dotscope/storage/mcp_config.py +98 -0
  101. dotscope/storage/near_miss.py +183 -0
  102. dotscope/storage/onboarding.py +150 -0
  103. dotscope/storage/session_manager.py +195 -0
  104. dotscope/storage/timing.py +84 -0
  105. dotscope/timing.py +2 -0
  106. dotscope/tokens.py +53 -0
  107. dotscope/utility.py +123 -0
  108. dotscope/virtual.py +3 -0
  109. dotscope/visibility.py +664 -0
  110. dotscope-0.1.0.dist-info/METADATA +50 -0
  111. dotscope-0.1.0.dist-info/RECORD +114 -0
  112. dotscope-0.1.0.dist-info/WHEEL +4 -0
  113. dotscope-0.1.0.dist-info/entry_points.txt +3 -0
  114. dotscope-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)