etch-loop 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- etch_loop-0.2.0/PKG-INFO +117 -0
- etch_loop-0.2.0/README.md +105 -0
- {etch_loop-0.1.0 → etch_loop-0.2.0}/pyproject.toml +1 -1
- etch_loop-0.2.0/src/etch/__init__.py +1 -0
- etch_loop-0.2.0/src/etch/analyze.py +306 -0
- etch_loop-0.2.0/src/etch/cli.py +107 -0
- {etch_loop-0.1.0 → etch_loop-0.2.0}/src/etch/display.py +162 -42
- {etch_loop-0.1.0 → etch_loop-0.2.0}/src/etch/loop.py +85 -6
- {etch_loop-0.1.0 → etch_loop-0.2.0}/src/etch/prompt.py +30 -0
- {etch_loop-0.1.0 → etch_loop-0.2.0}/src/etch/signals.py +39 -3
- etch_loop-0.2.0/src/etch/templates/BREAK.md +28 -0
- etch_loop-0.2.0/src/etch/templates/SCAN.md +30 -0
- etch_loop-0.1.0/PKG-INFO +0 -13
- etch_loop-0.1.0/README.md +0 -1
- etch_loop-0.1.0/src/etch/__init__.py +0 -1
- etch_loop-0.1.0/src/etch/cli.py +0 -90
- etch_loop-0.1.0/src/etch/templates/BREAK.md +0 -21
- {etch_loop-0.1.0 → etch_loop-0.2.0}/.github/workflows/workflow.yml +0 -0
- {etch_loop-0.1.0 → etch_loop-0.2.0}/src/etch/agent.py +0 -0
- {etch_loop-0.1.0 → etch_loop-0.2.0}/src/etch/git.py +0 -0
- {etch_loop-0.1.0 → etch_loop-0.2.0}/src/etch/templates/ETCH.md +0 -0
- {etch_loop-0.1.0 → etch_loop-0.2.0}/tests/__init__.py +0 -0
- {etch_loop-0.1.0 → etch_loop-0.2.0}/tests/test_git.py +0 -0
- {etch_loop-0.1.0 → etch_loop-0.2.0}/tests/test_loop.py +0 -0
- {etch_loop-0.1.0 → etch_loop-0.2.0}/tests/test_prompt.py +0 -0
- {etch_loop-0.1.0 → etch_loop-0.2.0}/tests/test_signals.py +0 -0
etch_loop-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: etch-loop
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Run Claude Code in a fix-break loop until your codebase is clean
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: rich
|
|
8
|
+
Requires-Dist: typer
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
███████╗████████╗ ██████╗██╗ ██╗
|
|
15
|
+
██╔════╝╚══██╔══╝██╔════╝██║ ██║
|
|
16
|
+
█████╗ ██║ ██║ ███████║
|
|
17
|
+
██╔══╝ ██║ ██║ ██╔══██║
|
|
18
|
+
███████╗ ██║ ╚██████╗██║ ██║
|
|
19
|
+
╚══════╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ loop
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
> Run Claude Code in a scan-fix-break loop until your codebase is clean.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
┌─ etch loop v0.2.0 . ───────────────────────────────────────────────┐
|
|
28
|
+
│ │
|
|
29
|
+
│ - iteration 1 │
|
|
30
|
+
│ + scanner issues found src/auth.py:42 — no empty token check │
|
|
31
|
+
│ + fixer committed fix(edge): guard empty token in auth │
|
|
32
|
+
│ x breaker issues unguarded access still reachable │
|
|
33
|
+
│ │
|
|
34
|
+
│ - iteration 2 │
|
|
35
|
+
│ + scanner issues found src/auth.py:61 — missing None check │
|
|
36
|
+
│ + fixer committed fix(edge): null guard on session obj │
|
|
37
|
+
│ > breaker running ░░░░░░▓▒ ░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
|
38
|
+
│ │
|
|
39
|
+
├──────────────────────────────────────────────────────────────────────┤
|
|
40
|
+
│ iterations 2 fixes 2 breaker issues 1 1m 48s elapsed │
|
|
41
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv tool install etch-loop
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
etch init # analyze codebase with Claude, write prompt files
|
|
56
|
+
etch run # start the loop
|
|
57
|
+
etch run "the auth module" # focus on a specific area
|
|
58
|
+
etch run -n 5 # max 5 iterations
|
|
59
|
+
etch run --dry-run # preview prompt, don't run
|
|
60
|
+
etch run --verbose # show full Claude output
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## how it works
|
|
66
|
+
|
|
67
|
+
Each iteration has three phases: **scan → fix → break**.
|
|
68
|
+
|
|
69
|
+
1. **Scanner** reads the codebase and outputs a specific list of issues — file paths, line numbers, descriptions
|
|
70
|
+
2. If the scanner finds nothing, the loop stops
|
|
71
|
+
3. **Fixer** receives the scanner's list and fixes those exact issues, then commits
|
|
72
|
+
4. **Breaker** adversarially reviews the full codebase, looking for anything missed or newly introduced
|
|
73
|
+
5. If the breaker finds nothing, the loop stops — clean pass
|
|
74
|
+
6. If the breaker finds something, it's fed back to the next iteration's fixer
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
┌─ done ───────────────────────────────────────────────────┐
|
|
78
|
+
│ │
|
|
79
|
+
│ iterations 3 │
|
|
80
|
+
│ fixes 3 │
|
|
81
|
+
│ breaker issues 1 │
|
|
82
|
+
│ elapsed 2m 44s │
|
|
83
|
+
│ │
|
|
84
|
+
└──────────────────────────────────────────────────────────┘
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## etch init
|
|
90
|
+
|
|
91
|
+
`etch init` runs Claude against your codebase before writing any files. It reads your source, detects the languages and structure, and generates three prompt files tailored to your project — no placeholders to edit.
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
┌─ etch init v0.2.0 ───────────────────────────────────────┐
|
|
95
|
+
│ > analyzing ░░░░░▓▒ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
|
96
|
+
│ + analyzed codebase │
|
|
97
|
+
│ + SCAN.md │
|
|
98
|
+
│ + ETCH.md │
|
|
99
|
+
│ + BREAK.md │
|
|
100
|
+
└──────────────────────────────────────────────────────────┘
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**`SCAN.md`** — tells the scanner what to look for and how to report findings.
|
|
104
|
+
|
|
105
|
+
**`ETCH.md`** — tells the fixer how to fix things: surgical, no refactoring, one fix per commit.
|
|
106
|
+
|
|
107
|
+
**`BREAK.md`** — tells the breaker to scan the full codebase adversarially and report anything that could go wrong.
|
|
108
|
+
|
|
109
|
+
All three files are editable. Use `etch run "focus description"` to narrow the scope without editing files.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## requirements
|
|
114
|
+
|
|
115
|
+
- Python 3.11+
|
|
116
|
+
- [`claude`](https://claude.ai/code) CLI installed and authenticated
|
|
117
|
+
- A git repository (etch-loop commits each fix automatically)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
```
|
|
2
|
+
███████╗████████╗ ██████╗██╗ ██╗
|
|
3
|
+
██╔════╝╚══██╔══╝██╔════╝██║ ██║
|
|
4
|
+
█████╗ ██║ ██║ ███████║
|
|
5
|
+
██╔══╝ ██║ ██║ ██╔══██║
|
|
6
|
+
███████╗ ██║ ╚██████╗██║ ██║
|
|
7
|
+
╚══════╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ loop
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
> Run Claude Code in a scan-fix-break loop until your codebase is clean.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
┌─ etch loop v0.2.0 . ───────────────────────────────────────────────┐
|
|
16
|
+
│ │
|
|
17
|
+
│ - iteration 1 │
|
|
18
|
+
│ + scanner issues found src/auth.py:42 — no empty token check │
|
|
19
|
+
│ + fixer committed fix(edge): guard empty token in auth │
|
|
20
|
+
│ x breaker issues unguarded access still reachable │
|
|
21
|
+
│ │
|
|
22
|
+
│ - iteration 2 │
|
|
23
|
+
│ + scanner issues found src/auth.py:61 — missing None check │
|
|
24
|
+
│ + fixer committed fix(edge): null guard on session obj │
|
|
25
|
+
│ > breaker running ░░░░░░▓▒ ░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
|
26
|
+
│ │
|
|
27
|
+
├──────────────────────────────────────────────────────────────────────┤
|
|
28
|
+
│ iterations 2 fixes 2 breaker issues 1 1m 48s elapsed │
|
|
29
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv tool install etch-loop
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## usage
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
etch init # analyze codebase with Claude, write prompt files
|
|
44
|
+
etch run # start the loop
|
|
45
|
+
etch run "the auth module" # focus on a specific area
|
|
46
|
+
etch run -n 5 # max 5 iterations
|
|
47
|
+
etch run --dry-run # preview prompt, don't run
|
|
48
|
+
etch run --verbose # show full Claude output
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## how it works
|
|
54
|
+
|
|
55
|
+
Each iteration has three phases: **scan → fix → break**.
|
|
56
|
+
|
|
57
|
+
1. **Scanner** reads the codebase and outputs a specific list of issues — file paths, line numbers, descriptions
|
|
58
|
+
2. If the scanner finds nothing, the loop stops
|
|
59
|
+
3. **Fixer** receives the scanner's list and fixes those exact issues, then commits
|
|
60
|
+
4. **Breaker** adversarially reviews the full codebase, looking for anything missed or newly introduced
|
|
61
|
+
5. If the breaker finds nothing, the loop stops — clean pass
|
|
62
|
+
6. If the breaker finds something, it's fed back to the next iteration's fixer
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
┌─ done ───────────────────────────────────────────────────┐
|
|
66
|
+
│ │
|
|
67
|
+
│ iterations 3 │
|
|
68
|
+
│ fixes 3 │
|
|
69
|
+
│ breaker issues 1 │
|
|
70
|
+
│ elapsed 2m 44s │
|
|
71
|
+
│ │
|
|
72
|
+
└──────────────────────────────────────────────────────────┘
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## etch init
|
|
78
|
+
|
|
79
|
+
`etch init` runs Claude against your codebase before writing any files. It reads your source, detects the languages and structure, and generates three prompt files tailored to your project — no placeholders to edit.
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
┌─ etch init v0.2.0 ───────────────────────────────────────┐
|
|
83
|
+
│ > analyzing ░░░░░▓▒ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
|
|
84
|
+
│ + analyzed codebase │
|
|
85
|
+
│ + SCAN.md │
|
|
86
|
+
│ + ETCH.md │
|
|
87
|
+
│ + BREAK.md │
|
|
88
|
+
└──────────────────────────────────────────────────────────┘
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**`SCAN.md`** — tells the scanner what to look for and how to report findings.
|
|
92
|
+
|
|
93
|
+
**`ETCH.md`** — tells the fixer how to fix things: surgical, no refactoring, one fix per commit.
|
|
94
|
+
|
|
95
|
+
**`BREAK.md`** — tells the breaker to scan the full codebase adversarially and report anything that could go wrong.
|
|
96
|
+
|
|
97
|
+
All three files are editable. Use `etch run "focus description"` to narrow the scope without editing files.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## requirements
|
|
102
|
+
|
|
103
|
+
- Python 3.11+
|
|
104
|
+
- [`claude`](https://claude.ai/code) CLI installed and authenticated
|
|
105
|
+
- A git repository (etch-loop commits each fix automatically)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Codebase analysis for generating tailored prompt files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_LANG_MAP: dict[str, str] = {
|
|
11
|
+
".py": "Python",
|
|
12
|
+
".ts": "TypeScript",
|
|
13
|
+
".tsx": "TypeScript",
|
|
14
|
+
".js": "JavaScript",
|
|
15
|
+
".jsx": "JavaScript",
|
|
16
|
+
".go": "Go",
|
|
17
|
+
".rs": "Rust",
|
|
18
|
+
".rb": "Ruby",
|
|
19
|
+
".java": "Java",
|
|
20
|
+
".kt": "Kotlin",
|
|
21
|
+
".swift": "Swift",
|
|
22
|
+
".cpp": "C++",
|
|
23
|
+
".c": "C",
|
|
24
|
+
".cs": "C#",
|
|
25
|
+
".php": "PHP",
|
|
26
|
+
".ex": "Elixir",
|
|
27
|
+
".exs": "Elixir",
|
|
28
|
+
".hs": "Haskell",
|
|
29
|
+
".scala": "Scala",
|
|
30
|
+
".clj": "Clojure",
|
|
31
|
+
".lua": "Lua",
|
|
32
|
+
".sh": "Shell",
|
|
33
|
+
".bash": "Shell",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_FRAMEWORK_HINTS: dict[str, str] = {
|
|
37
|
+
"pyproject.toml": "Python project",
|
|
38
|
+
"setup.py": "Python project",
|
|
39
|
+
"package.json": "Node.js project",
|
|
40
|
+
"go.mod": "Go module",
|
|
41
|
+
"Cargo.toml": "Rust crate",
|
|
42
|
+
"Gemfile": "Ruby project",
|
|
43
|
+
"pom.xml": "Java/Maven project",
|
|
44
|
+
"build.gradle": "Java/Gradle project",
|
|
45
|
+
"mix.exs": "Elixir/Mix project",
|
|
46
|
+
"composer.json": "PHP project",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_SKIP_DIRS = {
|
|
50
|
+
".git", "node_modules", "__pycache__", ".venv", "venv", "env",
|
|
51
|
+
"dist", "build", ".next", "target", "vendor", ".cache",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def analyze(root: Path | None = None) -> dict:
|
|
56
|
+
"""Analyze a codebase and return structured info.
|
|
57
|
+
|
|
58
|
+
Returns a dict with keys:
|
|
59
|
+
languages: list of (language, file_count) sorted by count
|
|
60
|
+
source_dirs: list of top-level source directories
|
|
61
|
+
entry_points: list of likely entry point files
|
|
62
|
+
framework: detected framework/project type string or None
|
|
63
|
+
total_files: int
|
|
64
|
+
is_git: bool
|
|
65
|
+
"""
|
|
66
|
+
root = root or Path.cwd()
|
|
67
|
+
|
|
68
|
+
files = _list_files(root)
|
|
69
|
+
total = len(files)
|
|
70
|
+
|
|
71
|
+
# Language detection
|
|
72
|
+
ext_counts: Counter[str] = Counter()
|
|
73
|
+
for f in files:
|
|
74
|
+
ext = Path(f).suffix.lower()
|
|
75
|
+
if ext in _LANG_MAP:
|
|
76
|
+
ext_counts[ext] += 1
|
|
77
|
+
|
|
78
|
+
lang_counts: Counter[str] = Counter()
|
|
79
|
+
for ext, count in ext_counts.items():
|
|
80
|
+
lang_counts[_LANG_MAP[ext]] += count
|
|
81
|
+
|
|
82
|
+
languages = lang_counts.most_common(3)
|
|
83
|
+
|
|
84
|
+
# Source directories (top-level dirs that contain source files)
|
|
85
|
+
top_dirs: Counter[str] = Counter()
|
|
86
|
+
for f in files:
|
|
87
|
+
parts = Path(f).parts
|
|
88
|
+
if len(parts) > 1 and parts[0] not in _SKIP_DIRS:
|
|
89
|
+
top_dirs[parts[0]] += 1
|
|
90
|
+
|
|
91
|
+
source_dirs = [d for d, _ in top_dirs.most_common(5) if not d.startswith(".")]
|
|
92
|
+
|
|
93
|
+
# Entry points
|
|
94
|
+
entry_points = _find_entry_points(root, files)
|
|
95
|
+
|
|
96
|
+
# Framework detection
|
|
97
|
+
framework = None
|
|
98
|
+
for marker, label in _FRAMEWORK_HINTS.items():
|
|
99
|
+
if (root / marker).exists():
|
|
100
|
+
framework = label
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"languages": languages,
|
|
105
|
+
"source_dirs": source_dirs,
|
|
106
|
+
"entry_points": entry_points,
|
|
107
|
+
"framework": framework,
|
|
108
|
+
"total_files": total,
|
|
109
|
+
"is_git": (root / ".git").exists(),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_init_prompt(info: dict) -> str:
|
|
114
|
+
"""Build the Claude prompt used during etch init to analyze the codebase."""
|
|
115
|
+
file_tree = "\n".join(f" {f}" for f in _list_files(Path.cwd())[:60])
|
|
116
|
+
if not file_tree:
|
|
117
|
+
file_tree = " (no tracked files)"
|
|
118
|
+
|
|
119
|
+
lang_summary = ", ".join(f"{lang} ({n})" for lang, n in info["languages"]) or "unknown"
|
|
120
|
+
framework = info["framework"] or "unknown"
|
|
121
|
+
|
|
122
|
+
return f"""You are analyzing a codebase to configure an automated edge-case hunting tool.
|
|
123
|
+
|
|
124
|
+
The tool will run Claude Code in a fix-break loop to find and patch edge cases.
|
|
125
|
+
Your job is to write a focused scope description that tells the fixer exactly where to look.
|
|
126
|
+
|
|
127
|
+
## Codebase stats
|
|
128
|
+
- Framework: {framework}
|
|
129
|
+
- Languages: {lang_summary}
|
|
130
|
+
- Total files: {info["total_files"]}
|
|
131
|
+
|
|
132
|
+
## File tree
|
|
133
|
+
{file_tree}
|
|
134
|
+
|
|
135
|
+
## Instructions
|
|
136
|
+
|
|
137
|
+
Read the key source files in this codebase. Then write a concise scope description covering:
|
|
138
|
+
- The highest-risk areas for edge cases in THIS specific codebase
|
|
139
|
+
- Specific files or modules worth focusing on
|
|
140
|
+
- Any patterns you spotted that suggest missing error handling
|
|
141
|
+
|
|
142
|
+
Rules:
|
|
143
|
+
- Output ONLY the scope description as plain prose or bullet points
|
|
144
|
+
- No markdown headers, no preamble, no "here is the scope" intro
|
|
145
|
+
- Be specific to this codebase — not generic advice
|
|
146
|
+
- Keep it under 150 words
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_scan_md(info: dict, agent_scope: str | None = None) -> str:
|
|
151
|
+
"""Generate a tailored SCAN.md from analysis results."""
|
|
152
|
+
scope = agent_scope.strip() if agent_scope else _format_scope(info)
|
|
153
|
+
|
|
154
|
+
return f"""# SCAN — scanner prompt
|
|
155
|
+
|
|
156
|
+
You are a code analyst. Your job is to find edge cases before the fixer runs.
|
|
157
|
+
|
|
158
|
+
## Your mission
|
|
159
|
+
|
|
160
|
+
Read the codebase and produce a precise, actionable list of issues:
|
|
161
|
+
- Unhandled edge cases and boundary conditions
|
|
162
|
+
- Missing null/None/empty checks
|
|
163
|
+
- Unhandled exceptions and error paths
|
|
164
|
+
- Off-by-one errors
|
|
165
|
+
- Race conditions or unsafe concurrent access
|
|
166
|
+
- Missing input validation at system boundaries
|
|
167
|
+
|
|
168
|
+
For each issue, include the file path, line number (if known), and a one-line description.
|
|
169
|
+
|
|
170
|
+
## Rules
|
|
171
|
+
|
|
172
|
+
1. DO NOT edit any files — read only
|
|
173
|
+
2. List each issue on its own line, e.g.:
|
|
174
|
+
- src/auth.py:42 — no check for empty token string
|
|
175
|
+
- src/api.js:108 — unhandled promise rejection in fetchUser()
|
|
176
|
+
3. Be specific — vague findings are not useful
|
|
177
|
+
4. End your output with EXACTLY one of these tokens on its own line:
|
|
178
|
+
- `ETCH_ISSUES_FOUND` — if you found issues worth fixing
|
|
179
|
+
- `ETCH_ALL_CLEAR` — if the code looks solid
|
|
180
|
+
|
|
181
|
+
## Scope
|
|
182
|
+
|
|
183
|
+
{scope}
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def build_etch_md(info: dict, agent_scope: str | None = None) -> str:
|
|
188
|
+
"""Generate a tailored ETCH.md from analysis results."""
|
|
189
|
+
scope = agent_scope.strip() if agent_scope else _format_scope(info)
|
|
190
|
+
|
|
191
|
+
return f"""# ETCH — fixer prompt
|
|
192
|
+
|
|
193
|
+
You are a surgical code reviewer focused on edge cases and robustness.
|
|
194
|
+
|
|
195
|
+
## Your mission
|
|
196
|
+
|
|
197
|
+
Scan the codebase for:
|
|
198
|
+
- Unhandled edge cases and boundary conditions
|
|
199
|
+
- Missing null/None/empty checks
|
|
200
|
+
- Unhandled exceptions and error paths
|
|
201
|
+
- Off-by-one errors
|
|
202
|
+
- Race conditions or unsafe concurrent access
|
|
203
|
+
- Missing input validation at system boundaries
|
|
204
|
+
|
|
205
|
+
## Rules
|
|
206
|
+
|
|
207
|
+
1. Fix only what you find — do not refactor, rename, or reorganize
|
|
208
|
+
2. One logical fix per commit (the harness will commit for you)
|
|
209
|
+
3. Do not add comments explaining what you fixed
|
|
210
|
+
4. If you find nothing, make no changes
|
|
211
|
+
|
|
212
|
+
## Scope
|
|
213
|
+
|
|
214
|
+
{scope}
|
|
215
|
+
|
|
216
|
+
## Commit format
|
|
217
|
+
|
|
218
|
+
The harness commits automatically. Each commit will be:
|
|
219
|
+
fix(edge): <short description of what was fixed>
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def build_break_md(info: dict, agent_scope: str | None = None) -> str:
|
|
224
|
+
"""Generate a tailored BREAK.md from analysis results."""
|
|
225
|
+
scope = agent_scope.strip() if agent_scope else _format_scope(info)
|
|
226
|
+
|
|
227
|
+
return f"""# BREAK — breaker prompt
|
|
228
|
+
|
|
229
|
+
You are an adversarial code reviewer. Your job is to find anything that could go wrong.
|
|
230
|
+
|
|
231
|
+
## Your mission
|
|
232
|
+
|
|
233
|
+
Scan the entire codebase with fresh eyes. Do not limit yourself to recent changes.
|
|
234
|
+
|
|
235
|
+
Look for:
|
|
236
|
+
- Edge cases and boundary conditions that are unhandled anywhere in the code
|
|
237
|
+
- Functions that assume valid input without checking
|
|
238
|
+
- Error paths that are silently swallowed or ignored
|
|
239
|
+
- Race conditions, off-by-one errors, null/empty/zero not guarded
|
|
240
|
+
- Anything that would cause unexpected behavior in production
|
|
241
|
+
|
|
242
|
+
Be adversarial — think like someone actively trying to make this code fail.
|
|
243
|
+
|
|
244
|
+
## Rules
|
|
245
|
+
|
|
246
|
+
1. DO NOT edit any files — read only
|
|
247
|
+
2. Report your findings clearly, one per line
|
|
248
|
+
3. End your output with EXACTLY one of these tokens on its own line:
|
|
249
|
+
- `ETCH_ISSUES_FOUND` — if you found anything worth fixing
|
|
250
|
+
- `ETCH_ALL_CLEAR` — if the code looks solid
|
|
251
|
+
|
|
252
|
+
## Scope
|
|
253
|
+
|
|
254
|
+
{scope}
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _format_scope(info: dict) -> str:
|
|
259
|
+
lines = []
|
|
260
|
+
if info["source_dirs"]:
|
|
261
|
+
dirs = " ".join(f"{d}/" for d in info["source_dirs"])
|
|
262
|
+
lines.append(f"Directories: {dirs}")
|
|
263
|
+
if info["entry_points"]:
|
|
264
|
+
eps = " ".join(info["entry_points"][:3])
|
|
265
|
+
lines.append(f"Entry points: {eps}")
|
|
266
|
+
if info["framework"]:
|
|
267
|
+
lines.append(f"Project type: {info['framework']}")
|
|
268
|
+
lines.append(f"Total tracked files: {info['total_files']}")
|
|
269
|
+
return "\n".join(lines) if lines else "Entire repository"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _list_files(root: Path) -> list[str]:
|
|
273
|
+
"""Return tracked files via git ls-files, falling back to filesystem walk."""
|
|
274
|
+
try:
|
|
275
|
+
result = subprocess.run(
|
|
276
|
+
["git", "ls-files"],
|
|
277
|
+
cwd=root,
|
|
278
|
+
capture_output=True,
|
|
279
|
+
text=True,
|
|
280
|
+
timeout=10,
|
|
281
|
+
)
|
|
282
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
283
|
+
return result.stdout.strip().splitlines()
|
|
284
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
# Fallback: walk filesystem
|
|
288
|
+
files = []
|
|
289
|
+
for p in root.rglob("*"):
|
|
290
|
+
if p.is_file() and not any(part in _SKIP_DIRS for part in p.parts):
|
|
291
|
+
try:
|
|
292
|
+
files.append(str(p.relative_to(root)))
|
|
293
|
+
except ValueError:
|
|
294
|
+
pass
|
|
295
|
+
return files
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _find_entry_points(root: Path, files: list[str]) -> list[str]:
|
|
299
|
+
"""Heuristically identify entry point files."""
|
|
300
|
+
candidates = []
|
|
301
|
+
names = {"main.py", "app.py", "index.ts", "index.js", "main.go",
|
|
302
|
+
"main.rs", "server.py", "server.ts", "cli.py", "manage.py"}
|
|
303
|
+
for f in files:
|
|
304
|
+
if Path(f).name in names:
|
|
305
|
+
candidates.append(f)
|
|
306
|
+
return candidates[:4]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""CLI entry points for etch-loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from etch import agent, analyze, display, loop
|
|
10
|
+
from etch.agent import AgentError
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="etch",
|
|
14
|
+
help="Run Claude Code in a fix-break loop, hunting for edge cases.",
|
|
15
|
+
add_completion=False,
|
|
16
|
+
pretty_exceptions_show_locals=False,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def init() -> None:
|
|
22
|
+
"""Analyze the codebase with Claude and write tailored SCAN.md, ETCH.md, BREAK.md."""
|
|
23
|
+
root = Path.cwd()
|
|
24
|
+
info = analyze.analyze(root)
|
|
25
|
+
init_prompt = analyze.build_init_prompt(info)
|
|
26
|
+
|
|
27
|
+
agent_scope: str | None = None
|
|
28
|
+
with display.InitDisplay() as disp:
|
|
29
|
+
disp.start_scan()
|
|
30
|
+
try:
|
|
31
|
+
agent_scope = agent.run(init_prompt)
|
|
32
|
+
disp.finish_scan(success=True)
|
|
33
|
+
except AgentError as exc:
|
|
34
|
+
disp.finish_scan(success=False)
|
|
35
|
+
disp.add_line(display.SYM_NEUTRAL, display.DIM, f"falling back to static analysis ({exc})")
|
|
36
|
+
|
|
37
|
+
for dest, content, label in [
|
|
38
|
+
(root / "SCAN.md", analyze.build_scan_md(info, agent_scope), "SCAN.md"),
|
|
39
|
+
(root / "ETCH.md", analyze.build_etch_md(info, agent_scope), "ETCH.md"),
|
|
40
|
+
(root / "BREAK.md", analyze.build_break_md(info, agent_scope), "BREAK.md"),
|
|
41
|
+
]:
|
|
42
|
+
if dest.exists():
|
|
43
|
+
disp.add_line(display.SYM_NEUTRAL, display.DIM, f"{label} already exists, skipping")
|
|
44
|
+
else:
|
|
45
|
+
dest.write_text(content, encoding="utf-8")
|
|
46
|
+
disp.add_line(display.SYM_OK, display.GREEN, label)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def run(
|
|
51
|
+
focus: str = typer.Argument(
|
|
52
|
+
default=None,
|
|
53
|
+
help="Optional focus description, e.g. 'the auth module' or 'error handling in payments'.",
|
|
54
|
+
),
|
|
55
|
+
prompt: str = typer.Option(
|
|
56
|
+
"./ETCH.md",
|
|
57
|
+
"--prompt",
|
|
58
|
+
help="Path to the fixer prompt file (ETCH.md).",
|
|
59
|
+
show_default=True,
|
|
60
|
+
),
|
|
61
|
+
max_iterations: int = typer.Option(
|
|
62
|
+
20,
|
|
63
|
+
"--max-iterations",
|
|
64
|
+
"-n",
|
|
65
|
+
help="Maximum number of fix-break cycles.",
|
|
66
|
+
show_default=True,
|
|
67
|
+
min=1,
|
|
68
|
+
),
|
|
69
|
+
no_commit: bool = typer.Option(
|
|
70
|
+
False,
|
|
71
|
+
"--no-commit",
|
|
72
|
+
help="Skip git commits after fixer runs.",
|
|
73
|
+
is_flag=True,
|
|
74
|
+
),
|
|
75
|
+
dry_run: bool = typer.Option(
|
|
76
|
+
False,
|
|
77
|
+
"--dry-run",
|
|
78
|
+
help="Print the fixer prompt and exit without running.",
|
|
79
|
+
is_flag=True,
|
|
80
|
+
),
|
|
81
|
+
verbose: bool = typer.Option(
|
|
82
|
+
False,
|
|
83
|
+
"--verbose",
|
|
84
|
+
help="Stream agent output to the terminal.",
|
|
85
|
+
is_flag=True,
|
|
86
|
+
),
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Run the fix-break loop against the current repository.
|
|
89
|
+
|
|
90
|
+
Optionally pass a focus description to narrow the scan:
|
|
91
|
+
|
|
92
|
+
etch run "the authentication module"
|
|
93
|
+
|
|
94
|
+
etch run "error handling in the payments flow"
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
loop.run(
|
|
98
|
+
prompt_path=prompt,
|
|
99
|
+
max_iterations=max_iterations,
|
|
100
|
+
no_commit=no_commit,
|
|
101
|
+
dry_run=dry_run,
|
|
102
|
+
verbose=verbose,
|
|
103
|
+
focus=focus,
|
|
104
|
+
)
|
|
105
|
+
except KeyboardInterrupt:
|
|
106
|
+
display.print_interrupted()
|
|
107
|
+
raise typer.Exit(code=130)
|