patchwork-conventions 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.
- patchwork/__init__.py +10 -0
- patchwork/cli.py +336 -0
- patchwork/mcp/__init__.py +1 -0
- patchwork/mcp/server.py +442 -0
- patchwork/miners/__init__.py +1 -0
- patchwork/miners/api_patterns.py +204 -0
- patchwork/miners/ast_base.py +113 -0
- patchwork/miners/config_detector.py +273 -0
- patchwork/miners/error_handling.py +207 -0
- patchwork/miners/git_patterns.py +169 -0
- patchwork/miners/imports.py +158 -0
- patchwork/miners/naming.py +277 -0
- patchwork/miners/structure.py +204 -0
- patchwork/miners/testing.py +204 -0
- patchwork/output/__init__.py +1 -0
- patchwork/output/report.py +417 -0
- patchwork/scanner.py +162 -0
- patchwork_conventions-0.1.0.dist-info/METADATA +393 -0
- patchwork_conventions-0.1.0.dist-info/RECORD +23 -0
- patchwork_conventions-0.1.0.dist-info/WHEEL +5 -0
- patchwork_conventions-0.1.0.dist-info/entry_points.txt +2 -0
- patchwork_conventions-0.1.0.dist-info/licenses/LICENSE +21 -0
- patchwork_conventions-0.1.0.dist-info/top_level.txt +1 -0
patchwork/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patchwork — Mine your codebase. Generate CONVENTIONS.md.
|
|
3
|
+
Stop AI agents from making up your style.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = ["scan", "ConventionReport"]
|
|
8
|
+
|
|
9
|
+
from patchwork.scanner import scan
|
|
10
|
+
from patchwork.output.report import ConventionReport
|
patchwork/cli.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
patchwork CLI — entry point for all commands.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
scan Scan a codebase and generate CONVENTIONS.md
|
|
6
|
+
update Re-scan, preserving any manual edits (merge mode)
|
|
7
|
+
diff Show what changed since last scan (exit 1 if changed)
|
|
8
|
+
watch Auto-regenerate on file changes
|
|
9
|
+
show Print detected conventions to terminal (no file write)
|
|
10
|
+
serve Start MCP server
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich import box
|
|
22
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
23
|
+
from rich.syntax import Syntax
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.group()
|
|
29
|
+
@click.version_option(package_name="patchwork-conventions")
|
|
30
|
+
def main() -> None:
|
|
31
|
+
"""patchwork — mine your codebase, generate CONVENTIONS.md."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@main.command()
|
|
35
|
+
@click.argument("path", default=".", type=click.Path(exists=True, file_okay=False))
|
|
36
|
+
@click.option("--output", "-o", default=None, help="Output file (default: CONVENTIONS.md in PATH)")
|
|
37
|
+
@click.option("--agents-md", is_flag=True, help="Write AGENTS.md instead of CONVENTIONS.md")
|
|
38
|
+
@click.option("--claude-md", is_flag=True, help="Append to CLAUDE.md instead")
|
|
39
|
+
@click.option("--json", "as_json", is_flag=True, help="Output JSON instead of Markdown")
|
|
40
|
+
@click.option("--no-git", is_flag=True, help="Skip git history analysis")
|
|
41
|
+
@click.option("--max-files", default=500, show_default=True, help="Max files to scan")
|
|
42
|
+
@click.option("--lang", multiple=True, help="Only scan these languages (e.g. --lang python)")
|
|
43
|
+
@click.option("--quiet", "-q", is_flag=True, help="Suppress all output except errors")
|
|
44
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed progress")
|
|
45
|
+
@click.option("--stdout", is_flag=True, help="Print to stdout instead of writing file")
|
|
46
|
+
def scan(
|
|
47
|
+
path: str,
|
|
48
|
+
output: str | None,
|
|
49
|
+
agents_md: bool,
|
|
50
|
+
claude_md: bool,
|
|
51
|
+
as_json: bool,
|
|
52
|
+
no_git: bool,
|
|
53
|
+
max_files: int,
|
|
54
|
+
lang: tuple[str, ...],
|
|
55
|
+
quiet: bool,
|
|
56
|
+
verbose: bool,
|
|
57
|
+
stdout: bool,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Scan a codebase and generate CONVENTIONS.md."""
|
|
60
|
+
from patchwork.scanner import scan as do_scan, ScanOptions
|
|
61
|
+
|
|
62
|
+
root = Path(path).resolve()
|
|
63
|
+
|
|
64
|
+
if not quiet:
|
|
65
|
+
console.print(
|
|
66
|
+
Panel.fit(
|
|
67
|
+
f"[bold cyan]patchwork[/bold cyan] scanning [green]{root}[/green]",
|
|
68
|
+
border_style="cyan",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
opts = ScanOptions(
|
|
73
|
+
root=root,
|
|
74
|
+
max_files=max_files,
|
|
75
|
+
include_git=not no_git,
|
|
76
|
+
languages=list(lang),
|
|
77
|
+
verbose=verbose,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
with Progress(
|
|
81
|
+
SpinnerColumn(),
|
|
82
|
+
TextColumn("[progress.description]{task.description}"),
|
|
83
|
+
transient=True,
|
|
84
|
+
disable=quiet,
|
|
85
|
+
) as progress:
|
|
86
|
+
progress.add_task("Mining conventions...", total=None)
|
|
87
|
+
report = do_scan(opts)
|
|
88
|
+
|
|
89
|
+
if as_json:
|
|
90
|
+
text = report.to_json()
|
|
91
|
+
else:
|
|
92
|
+
text = report.to_markdown(agents_md=agents_md)
|
|
93
|
+
|
|
94
|
+
if stdout:
|
|
95
|
+
click.echo(text)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
# Determine output path
|
|
99
|
+
if output:
|
|
100
|
+
out_path = Path(output)
|
|
101
|
+
elif claude_md:
|
|
102
|
+
out_path = root / "CLAUDE.md"
|
|
103
|
+
elif agents_md:
|
|
104
|
+
out_path = root / "AGENTS.md"
|
|
105
|
+
elif as_json:
|
|
106
|
+
out_path = root / ".patchwork" / "conventions.json"
|
|
107
|
+
else:
|
|
108
|
+
out_path = root / "CONVENTIONS.md"
|
|
109
|
+
|
|
110
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
|
|
112
|
+
if claude_md and out_path.exists():
|
|
113
|
+
# Append patchwork section to existing CLAUDE.md
|
|
114
|
+
existing = out_path.read_text()
|
|
115
|
+
marker = "<!-- patchwork:start -->"
|
|
116
|
+
end_marker = "<!-- patchwork:end -->"
|
|
117
|
+
if marker in existing:
|
|
118
|
+
# Replace existing patchwork section
|
|
119
|
+
import re
|
|
120
|
+
text = re.sub(
|
|
121
|
+
rf"{re.escape(marker)}.*?{re.escape(end_marker)}",
|
|
122
|
+
f"{marker}\n{text}\n{end_marker}",
|
|
123
|
+
existing,
|
|
124
|
+
flags=re.DOTALL,
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
text = existing.rstrip() + f"\n\n{marker}\n{text}\n{end_marker}\n"
|
|
128
|
+
|
|
129
|
+
out_path.write_text(text)
|
|
130
|
+
|
|
131
|
+
if not quiet:
|
|
132
|
+
_print_summary(report, out_path)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@main.command()
|
|
136
|
+
@click.argument("path", default=".", type=click.Path(exists=True, file_okay=False))
|
|
137
|
+
@click.option("--output", "-o", default=None, help="Output file (default: CONVENTIONS.md)")
|
|
138
|
+
def update(path: str, output: str | None) -> None:
|
|
139
|
+
"""Re-scan and update CONVENTIONS.md, preserving manual edits."""
|
|
140
|
+
from patchwork.scanner import scan as do_scan, ScanOptions
|
|
141
|
+
|
|
142
|
+
root = Path(path).resolve()
|
|
143
|
+
out_path = Path(output) if output else root / "CONVENTIONS.md"
|
|
144
|
+
|
|
145
|
+
# Load any existing manual annotations
|
|
146
|
+
manual_sections: dict[str, str] = {}
|
|
147
|
+
if out_path.exists():
|
|
148
|
+
manual_sections = _extract_manual_sections(out_path.read_text())
|
|
149
|
+
|
|
150
|
+
opts = ScanOptions(root=root)
|
|
151
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"), transient=True) as p:
|
|
152
|
+
p.add_task("Updating conventions...", total=None)
|
|
153
|
+
report = do_scan(opts)
|
|
154
|
+
|
|
155
|
+
text = report.to_markdown()
|
|
156
|
+
|
|
157
|
+
# Re-inject manual sections
|
|
158
|
+
for heading, content in manual_sections.items():
|
|
159
|
+
text += f"\n\n## {heading} (manual)\n\n{content}"
|
|
160
|
+
|
|
161
|
+
out_path.write_text(text)
|
|
162
|
+
console.print(f"[green]✓[/green] Updated [bold]{out_path}[/bold]")
|
|
163
|
+
if manual_sections:
|
|
164
|
+
console.print(f" Preserved {len(manual_sections)} manual section(s)")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@main.command()
|
|
168
|
+
@click.argument("path", default=".", type=click.Path(exists=True, file_okay=False))
|
|
169
|
+
def diff(path: str) -> None:
|
|
170
|
+
"""Show what would change in CONVENTIONS.md (exit 1 if changes detected)."""
|
|
171
|
+
import difflib
|
|
172
|
+
from patchwork.scanner import scan as do_scan, ScanOptions
|
|
173
|
+
|
|
174
|
+
root = Path(path).resolve()
|
|
175
|
+
out_path = root / "CONVENTIONS.md"
|
|
176
|
+
|
|
177
|
+
opts = ScanOptions(root=root)
|
|
178
|
+
report = do_scan(opts)
|
|
179
|
+
new_text = report.to_markdown()
|
|
180
|
+
|
|
181
|
+
if not out_path.exists():
|
|
182
|
+
console.print("[yellow]No existing CONVENTIONS.md — run `patchwork scan` first[/yellow]")
|
|
183
|
+
sys.exit(1)
|
|
184
|
+
|
|
185
|
+
old_text = out_path.read_text()
|
|
186
|
+
diffs = list(difflib.unified_diff(
|
|
187
|
+
old_text.splitlines(),
|
|
188
|
+
new_text.splitlines(),
|
|
189
|
+
fromfile="CONVENTIONS.md (current)",
|
|
190
|
+
tofile="CONVENTIONS.md (updated)",
|
|
191
|
+
lineterm="",
|
|
192
|
+
))
|
|
193
|
+
|
|
194
|
+
if not diffs:
|
|
195
|
+
console.print("[green]✓ CONVENTIONS.md is up to date[/green]")
|
|
196
|
+
sys.exit(0)
|
|
197
|
+
|
|
198
|
+
console.print(Syntax("\n".join(diffs[:100]), "diff"))
|
|
199
|
+
sys.exit(1)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@main.command()
|
|
203
|
+
@click.argument("path", default=".", type=click.Path(exists=True, file_okay=False))
|
|
204
|
+
@click.option("--interval", default=5.0, show_default=True, help="Seconds between rescans")
|
|
205
|
+
def watch(path: str, interval: float) -> None:
|
|
206
|
+
"""Watch for changes and auto-regenerate CONVENTIONS.md."""
|
|
207
|
+
import time
|
|
208
|
+
from patchwork.scanner import scan as do_scan, ScanOptions
|
|
209
|
+
|
|
210
|
+
root = Path(path).resolve()
|
|
211
|
+
out_path = root / "CONVENTIONS.md"
|
|
212
|
+
opts = ScanOptions(root=root)
|
|
213
|
+
|
|
214
|
+
console.print(f"[cyan]Watching[/cyan] {root} (every {interval}s) — Ctrl+C to stop")
|
|
215
|
+
|
|
216
|
+
last_mtime: dict[str, float] = {}
|
|
217
|
+
|
|
218
|
+
def _get_mtimes() -> dict[str, float]:
|
|
219
|
+
mtimes = {}
|
|
220
|
+
for p in root.rglob("*"):
|
|
221
|
+
if p.is_file() and not any(
|
|
222
|
+
part.startswith(".") or part in ("node_modules", "__pycache__", "dist")
|
|
223
|
+
for part in p.parts
|
|
224
|
+
):
|
|
225
|
+
try:
|
|
226
|
+
mtimes[str(p)] = p.stat().st_mtime
|
|
227
|
+
except OSError:
|
|
228
|
+
pass
|
|
229
|
+
return mtimes
|
|
230
|
+
|
|
231
|
+
last_mtime = _get_mtimes()
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
while True:
|
|
235
|
+
time.sleep(interval)
|
|
236
|
+
current = _get_mtimes()
|
|
237
|
+
changed = (
|
|
238
|
+
set(current.keys()) != set(last_mtime.keys())
|
|
239
|
+
or any(current.get(k) != last_mtime.get(k) for k in current)
|
|
240
|
+
)
|
|
241
|
+
if changed:
|
|
242
|
+
last_mtime = current
|
|
243
|
+
report = do_scan(opts)
|
|
244
|
+
out_path.write_text(report.to_markdown())
|
|
245
|
+
console.print(f"[green]↺[/green] CONVENTIONS.md updated")
|
|
246
|
+
except KeyboardInterrupt:
|
|
247
|
+
console.print("\n[dim]Stopped[/dim]")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@main.command()
|
|
251
|
+
@click.argument("path", default=".", type=click.Path(exists=True, file_okay=False))
|
|
252
|
+
@click.option("--lang", multiple=True, help="Filter to specific languages")
|
|
253
|
+
def show(path: str, lang: tuple[str, ...]) -> None:
|
|
254
|
+
"""Print detected conventions to terminal without writing any file."""
|
|
255
|
+
from patchwork.scanner import scan as do_scan, ScanOptions
|
|
256
|
+
|
|
257
|
+
root = Path(path).resolve()
|
|
258
|
+
opts = ScanOptions(root=root, languages=list(lang))
|
|
259
|
+
|
|
260
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"), transient=True) as p:
|
|
261
|
+
p.add_task("Analysing...", total=None)
|
|
262
|
+
report = do_scan(opts)
|
|
263
|
+
|
|
264
|
+
_print_full_report(report)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@main.command()
|
|
268
|
+
@click.option("--port", default=3742, show_default=True, help="MCP server port")
|
|
269
|
+
@click.option("--stdio", is_flag=True, help="Use stdio transport (for Claude Code)")
|
|
270
|
+
@click.argument("path", default=".", type=click.Path(exists=True, file_okay=False))
|
|
271
|
+
def serve(port: int, stdio: bool, path: str) -> None:
|
|
272
|
+
"""Start the patchwork MCP server."""
|
|
273
|
+
import asyncio
|
|
274
|
+
from patchwork.mcp.server import run_server
|
|
275
|
+
|
|
276
|
+
root = Path(path).resolve()
|
|
277
|
+
asyncio.run(run_server(root=root, port=port, stdio=stdio))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── Rich terminal output helpers ──────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
def _print_summary(report, out_path: Path) -> None:
|
|
283
|
+
"""Print a compact summary after scan."""
|
|
284
|
+
table = Table(box=box.SIMPLE, show_header=False)
|
|
285
|
+
table.add_column("", style="dim")
|
|
286
|
+
table.add_column("")
|
|
287
|
+
|
|
288
|
+
table.add_row("Files scanned", str(report.file_count))
|
|
289
|
+
if report.by_lang:
|
|
290
|
+
langs = ", ".join(f"{lang} ({count})" for lang, count in sorted(report.by_lang.items()))
|
|
291
|
+
table.add_row("Languages", langs)
|
|
292
|
+
table.add_row("Time", f"{report.elapsed:.2f}s")
|
|
293
|
+
table.add_row("Output", str(out_path))
|
|
294
|
+
|
|
295
|
+
console.print(table)
|
|
296
|
+
|
|
297
|
+
# Highlight key findings
|
|
298
|
+
findings: list[str] = []
|
|
299
|
+
for lang, nr in (report.naming or {}).items():
|
|
300
|
+
if nr.functions:
|
|
301
|
+
findings.append(
|
|
302
|
+
f"{lang} functions: [cyan]{nr.functions.style}[/cyan] "
|
|
303
|
+
f"({int(nr.functions.confidence * 100)}%)"
|
|
304
|
+
)
|
|
305
|
+
if report.structure and report.structure.organisation:
|
|
306
|
+
findings.append(f"structure: [cyan]{report.structure.organisation}-based[/cyan]")
|
|
307
|
+
if report.git and report.git.commit_style:
|
|
308
|
+
findings.append(f"commits: [cyan]{report.git.commit_style}[/cyan]")
|
|
309
|
+
|
|
310
|
+
if findings:
|
|
311
|
+
console.print("\n[bold]Key findings:[/bold]")
|
|
312
|
+
for f in findings[:8]:
|
|
313
|
+
console.print(f" [green]✓[/green] {f}")
|
|
314
|
+
|
|
315
|
+
console.print(f"\n[bold green]✓[/bold green] Written to [bold]{out_path.name}[/bold]")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _print_full_report(report) -> None:
|
|
319
|
+
"""Full rich terminal output."""
|
|
320
|
+
md = report.to_markdown()
|
|
321
|
+
from rich.markdown import Markdown
|
|
322
|
+
console.print(Markdown(md))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _extract_manual_sections(text: str) -> dict[str, str]:
|
|
326
|
+
"""Extract sections marked with <!-- manual --> from existing file."""
|
|
327
|
+
import re
|
|
328
|
+
sections = {}
|
|
329
|
+
pattern = re.compile(r'## ([^\n]+) \(manual\)\n\n(.*?)(?=\n## |\Z)', re.DOTALL)
|
|
330
|
+
for m in pattern.finditer(text):
|
|
331
|
+
sections[m.group(1)] = m.group(2).strip()
|
|
332
|
+
return sections
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# mcp package
|