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 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