invar-tools 1.6.0__py3-none-any.whl → 1.7.1__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.
@@ -0,0 +1,479 @@
1
+ """
2
+ DX-69: Uninstall Invar from a project.
3
+
4
+ Safely removes Invar files and configurations while preserving user content.
5
+ Uses marker-based detection to identify Invar-generated content.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import shutil
13
+ from pathlib import Path
14
+
15
+ import typer
16
+ from rich.console import Console
17
+
18
+ from invar.shell.claude_hooks import is_invar_hook
19
+
20
+ console = Console()
21
+
22
+
23
+ def has_invar_marker(path: Path) -> bool:
24
+ """Check if a file has Invar markers (_invar: or <!--invar:)."""
25
+ try:
26
+ content = path.read_text()
27
+ return "_invar:" in content or "<!--invar:" in content
28
+ except (OSError, UnicodeDecodeError):
29
+ return False
30
+
31
+
32
+ def has_invar_region_marker(path: Path) -> bool:
33
+ """Check if a file has # invar:begin marker."""
34
+ try:
35
+ content = path.read_text()
36
+ return "# invar:begin" in content
37
+ except (OSError, UnicodeDecodeError):
38
+ return False
39
+
40
+
41
+ def has_invar_hook_marker(path: Path) -> bool:
42
+ """Check if a hook file has invar marker."""
43
+ try:
44
+ content = path.read_text()
45
+ # Invar hooks have specific patterns
46
+ return "invar" in content.lower() and (
47
+ "INVAR_" in content
48
+ or "invar guard" in content
49
+ or "invar_guard" in content
50
+ or "invar." in content.lower() # wrapper files: source invar.PreToolUse.sh
51
+ or "invar hook" in content.lower() # comment: # Invar hook wrapper
52
+ )
53
+ except (OSError, UnicodeDecodeError):
54
+ return False
55
+
56
+
57
+ # @shell_orchestration: Regex patterns tightly coupled to file removal logic
58
+ def _is_empty_user_region(content: str) -> bool:
59
+ """Check if user region only contains template comments (no real user content)."""
60
+ # Extract user region content
61
+ match = re.search(r"<!--invar:user-->(.*?)<!--/invar:user-->", content, flags=re.DOTALL)
62
+ if not match:
63
+ return True # No user region = empty
64
+
65
+ user_content = match.group(1)
66
+
67
+ # Remove all HTML/markdown comments
68
+ cleaned = re.sub(r"<!--.*?-->", "", user_content, flags=re.DOTALL)
69
+
70
+ # Remove invar-generated merge markers and headers
71
+ invar_patterns = [
72
+ r"## Claude Analysis \(Preserved\)\s*",
73
+ r"## My Custom Rules\s*",
74
+ r"- Rule \d+:.*\n?", # Template rules
75
+ ]
76
+ for pattern in invar_patterns:
77
+ cleaned = re.sub(pattern, "", cleaned)
78
+
79
+ # Remove whitespace
80
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
81
+
82
+ # If only whitespace or empty after removing comments and invar content, it's "empty"
83
+ return len(cleaned) == 0
84
+
85
+
86
+ # @shell_orchestration: Regex patterns tightly coupled to file removal logic
87
+ def remove_invar_regions(content: str) -> str:
88
+ """Remove <!--invar:xxx-->...<!--/invar:xxx--> regions.
89
+
90
+ User region is also removed if it only contains template comments.
91
+ Merge markers are always cleaned from user region.
92
+ """
93
+ patterns = [
94
+ # HTML-style regions (CLAUDE.md)
95
+ (r"<!--invar:critical-->.*?<!--/invar:critical-->\n?", ""),
96
+ (r"<!--invar:managed[^>]*-->.*?<!--/invar:managed-->\n?", ""),
97
+ (r"<!--invar:project-->.*?<!--/invar:project-->\n?", ""),
98
+ # Comment-style regions (.pre-commit-config.yaml)
99
+ (r"# invar:begin\n.*?# invar:end\n?", ""),
100
+ ]
101
+
102
+ # Also remove empty user region (only has template comments)
103
+ if _is_empty_user_region(content):
104
+ patterns.append((r"<!--invar:user-->.*?<!--/invar:user-->\n?", ""))
105
+ else:
106
+ # User region has real content - just remove the markers but keep content
107
+ patterns.append((r"<!--invar:user-->\n?", ""))
108
+ patterns.append((r"<!--/invar:user-->\n?", ""))
109
+ # Also clean invar-generated merge markers from user content
110
+ patterns.extend([
111
+ (r"<!-- =+ -->\n?", ""),
112
+ (r"<!-- MERGED CONTENT.*?-->\n?", ""),
113
+ (r"<!-- Original source:.*?-->\n?", ""),
114
+ (r"<!-- Merge date:.*?-->\n?", ""),
115
+ (r"<!-- END MERGED CONTENT -->\n?", ""),
116
+ (r"<!-- =+ -->\n?", ""),
117
+ (r"## Claude Analysis \(Preserved\)\n*", ""),
118
+ ])
119
+
120
+ for pattern, replacement in patterns:
121
+ content = re.sub(pattern, replacement, content, flags=re.DOTALL)
122
+
123
+ # Clean up trailing footer if nothing else left
124
+ content = re.sub(r"\n*---\n+\*Generated by.*?\*\s*$", "", content, flags=re.DOTALL)
125
+
126
+ # Clean up multiple blank lines
127
+ content = re.sub(r"\n{3,}", "\n\n", content)
128
+
129
+ return content.strip()
130
+
131
+
132
+ def remove_mcp_invar_entry(path: Path) -> tuple[bool, str]:
133
+ """Remove invar entry from .mcp.json, return (modified, new_content)."""
134
+ try:
135
+ content = path.read_text()
136
+ data = json.loads(content)
137
+ if "mcpServers" in data and "invar" in data["mcpServers"]:
138
+ del data["mcpServers"]["invar"]
139
+ # If no servers left, indicate file can be deleted
140
+ if not data["mcpServers"]:
141
+ return True, ""
142
+ return True, json.dumps(data, indent=2)
143
+ return False, content
144
+ except (OSError, json.JSONDecodeError):
145
+ return False, ""
146
+
147
+
148
+ # @shell_complexity: JSON parsing with conditional cleanup logic
149
+ def remove_hooks_from_settings(path: Path) -> tuple[bool, str]:
150
+ """Remove Invar hooks from .claude/settings.local.json.
151
+
152
+ Uses merge strategy:
153
+ - Only removes Invar hooks (identified by .claude/hooks/ path)
154
+ - Preserves user's existing hooks
155
+ - Cleans up empty hook types and hooks section
156
+ """
157
+ settings_path = path / ".claude" / "settings.local.json"
158
+
159
+ try:
160
+ if not settings_path.exists():
161
+ return False, ""
162
+ content = settings_path.read_text()
163
+ data = json.loads(content)
164
+
165
+ if "hooks" not in data:
166
+ return False, content
167
+
168
+ existing_hooks = data["hooks"]
169
+ modified = False
170
+
171
+ # Filter out Invar hooks from each hook type
172
+ for hook_type in list(existing_hooks.keys()):
173
+ hook_list = existing_hooks[hook_type]
174
+ if isinstance(hook_list, list):
175
+ # Keep only non-Invar hooks
176
+ filtered = [h for h in hook_list if not is_invar_hook(h)]
177
+ if len(filtered) != len(hook_list):
178
+ modified = True
179
+ if filtered:
180
+ existing_hooks[hook_type] = filtered
181
+ else:
182
+ # No hooks left for this type, remove the key
183
+ del existing_hooks[hook_type]
184
+
185
+ # If no hooks left, remove the hooks section entirely
186
+ if not existing_hooks:
187
+ del data["hooks"]
188
+
189
+ if not modified:
190
+ return False, content
191
+
192
+ # If nothing left in data, indicate file can be deleted
193
+ if not data:
194
+ return True, ""
195
+
196
+ return True, json.dumps(data, indent=2)
197
+ except (OSError, json.JSONDecodeError):
198
+ return False, ""
199
+
200
+
201
+ # @shell_complexity: Multi-file type detection requires comprehensive branching
202
+ def collect_removal_targets(path: Path) -> dict:
203
+ """Collect files and directories to remove/modify."""
204
+ targets = {
205
+ "delete_dirs": [],
206
+ "delete_files": [],
207
+ "modify_files": [],
208
+ "skip": [],
209
+ }
210
+
211
+ # Directories to delete entirely
212
+ invar_dir = path / ".invar"
213
+ if invar_dir.exists():
214
+ targets["delete_dirs"].append((".invar/", "directory"))
215
+
216
+ # Files to delete entirely
217
+ for file_name, description in [
218
+ ("invar.toml", "config"),
219
+ ("INVAR.md", "protocol"),
220
+ ]:
221
+ file_path = path / file_name
222
+ if file_path.exists():
223
+ targets["delete_files"].append((file_name, description))
224
+
225
+ # Skills with _invar marker
226
+ skills_dir = path / ".claude" / "skills"
227
+ if skills_dir.exists():
228
+ for skill_dir in skills_dir.iterdir():
229
+ if skill_dir.is_dir():
230
+ skill_file = skill_dir / "SKILL.md"
231
+ if skill_file.exists():
232
+ if has_invar_marker(skill_file):
233
+ targets["delete_dirs"].append(
234
+ (f".claude/skills/{skill_dir.name}/", "skill, has _invar marker")
235
+ )
236
+ else:
237
+ targets["skip"].append(
238
+ (f".claude/skills/{skill_dir.name}/", "no _invar marker")
239
+ )
240
+
241
+ # Commands with _invar marker
242
+ commands_dir = path / ".claude" / "commands"
243
+ if commands_dir.exists():
244
+ for cmd_file in commands_dir.glob("*.md"):
245
+ if has_invar_marker(cmd_file):
246
+ targets["delete_files"].append(
247
+ (f".claude/commands/{cmd_file.name}", "command, has _invar marker")
248
+ )
249
+ else:
250
+ targets["skip"].append(
251
+ (f".claude/commands/{cmd_file.name}", "no _invar marker")
252
+ )
253
+
254
+ # Hooks with invar marker
255
+ hooks_dir = path / ".claude" / "hooks"
256
+ if hooks_dir.exists():
257
+ for hook_file in hooks_dir.glob("*.sh"):
258
+ if has_invar_hook_marker(hook_file):
259
+ targets["delete_files"].append(
260
+ (f".claude/hooks/{hook_file.name}", "hook, has invar marker")
261
+ )
262
+
263
+ # CLAUDE.md - delete if empty user region, otherwise modify
264
+ claude_md = path / "CLAUDE.md"
265
+ if claude_md.exists():
266
+ content = claude_md.read_text()
267
+ if "<!--invar:" in content:
268
+ # Check if user region has real content
269
+ if _is_empty_user_region(content):
270
+ # Will be empty after cleanup - delete
271
+ targets["delete_files"].append(("CLAUDE.md", "no user content"))
272
+ else:
273
+ # Has user content - modify
274
+ targets["modify_files"].append(
275
+ ("CLAUDE.md", "remove invar regions, keep user content")
276
+ )
277
+
278
+ # .mcp.json - modify or delete
279
+ mcp_json = path / ".mcp.json"
280
+ if mcp_json.exists():
281
+ modified, new_content = remove_mcp_invar_entry(mcp_json)
282
+ if modified:
283
+ if new_content:
284
+ targets["modify_files"].append((".mcp.json", "remove mcpServers.invar"))
285
+ else:
286
+ targets["delete_files"].append((".mcp.json", "only had invar config"))
287
+
288
+ # settings.local.json - remove hooks section or delete if empty
289
+ settings_local = path / ".claude" / "settings.local.json"
290
+ if settings_local.exists():
291
+ modified, new_content = remove_hooks_from_settings(path)
292
+ if modified:
293
+ if new_content:
294
+ targets["modify_files"].append(
295
+ (".claude/settings.local.json", "remove hooks section")
296
+ )
297
+ else:
298
+ targets["delete_files"].append(
299
+ (".claude/settings.local.json", "only had hooks config")
300
+ )
301
+
302
+ # Config files with region markers (DX-69: cursor/aider removed)
303
+ for file_name in [".pre-commit-config.yaml"]:
304
+ file_path = path / file_name
305
+ if file_path.exists():
306
+ if has_invar_region_marker(file_path):
307
+ content = file_path.read_text()
308
+ cleaned = remove_invar_regions(content)
309
+ if cleaned:
310
+ targets["modify_files"].append((file_name, "remove invar:begin..end block"))
311
+ else:
312
+ targets["delete_files"].append((file_name, "only had invar content"))
313
+ else:
314
+ targets["skip"].append((file_name, "no invar:begin marker"))
315
+
316
+ # Empty directories to clean up
317
+ for dir_name in ["src/core", "src/shell"]:
318
+ dir_path = path / dir_name
319
+ if dir_path.exists() and dir_path.is_dir():
320
+ if not any(dir_path.iterdir()):
321
+ targets["delete_dirs"].append((dir_name, "empty directory"))
322
+
323
+ return targets
324
+
325
+
326
+ # @shell_complexity: Rich output formatting for different target categories
327
+ def show_preview(targets: dict) -> None:
328
+ """Display what would be removed/modified."""
329
+ console.print("\n[bold]Invar Uninstall Preview[/bold]")
330
+ console.print("=" * 40)
331
+
332
+ if targets["delete_dirs"] or targets["delete_files"]:
333
+ console.print("\n[red]Will DELETE:[/red]")
334
+ for item, desc in targets["delete_dirs"]:
335
+ console.print(f" {item:40} ({desc})")
336
+ for item, desc in targets["delete_files"]:
337
+ console.print(f" {item:40} ({desc})")
338
+
339
+ if targets["modify_files"]:
340
+ console.print("\n[yellow]Will MODIFY:[/yellow]")
341
+ for item, desc in targets["modify_files"]:
342
+ console.print(f" {item:40} ({desc})")
343
+
344
+ if targets["skip"]:
345
+ console.print("\n[dim]Will SKIP:[/dim]")
346
+ for item, desc in targets["skip"]:
347
+ console.print(f" {item:40} ({desc})")
348
+
349
+ console.print()
350
+
351
+
352
+ # @shell_complexity: Different file types require different removal strategies
353
+ def execute_removal(path: Path, targets: dict) -> None:
354
+ """Execute the removal/modification operations."""
355
+ # Delete directories
356
+ for dir_name, _ in targets["delete_dirs"]:
357
+ dir_path = path / dir_name.rstrip("/")
358
+ if dir_path.exists():
359
+ shutil.rmtree(dir_path)
360
+ console.print(f"[red]Deleted[/red] {dir_name}")
361
+
362
+ # Delete files
363
+ for file_name, _ in targets["delete_files"]:
364
+ file_path = path / file_name
365
+ if file_path.exists():
366
+ file_path.unlink()
367
+ console.print(f"[red]Deleted[/red] {file_name}")
368
+
369
+ # Modify files
370
+ for file_name, _desc in targets["modify_files"]:
371
+ file_path = path / file_name
372
+ if not file_path.exists():
373
+ continue
374
+
375
+ if file_name == ".mcp.json":
376
+ modified, new_content = remove_mcp_invar_entry(file_path)
377
+ if modified and new_content:
378
+ file_path.write_text(new_content)
379
+ console.print(f"[yellow]Modified[/yellow] {file_name}")
380
+ elif file_name == ".claude/settings.local.json":
381
+ modified, new_content = remove_hooks_from_settings(path)
382
+ if modified and new_content:
383
+ file_path.write_text(new_content)
384
+ console.print(f"[yellow]Modified[/yellow] {file_name}")
385
+ else:
386
+ content = file_path.read_text()
387
+ cleaned = remove_invar_regions(content)
388
+ if cleaned:
389
+ file_path.write_text(cleaned + "\n")
390
+ console.print(f"[yellow]Modified[/yellow] {file_name}")
391
+ else:
392
+ file_path.unlink()
393
+ console.print(f"[red]Deleted[/red] {file_name} (empty after cleanup)")
394
+
395
+ # Clean up empty .claude directory if it exists and is empty
396
+ claude_dir = path / ".claude"
397
+ if claude_dir.exists():
398
+ # Check subdirectories
399
+ for subdir in ["skills", "commands", "hooks"]:
400
+ subdir_path = claude_dir / subdir
401
+ if subdir_path.exists() and not any(subdir_path.iterdir()):
402
+ subdir_path.rmdir()
403
+ console.print(f"[dim]Removed empty[/dim] .claude/{subdir}/")
404
+ # Check if .claude itself is empty
405
+ if not any(claude_dir.iterdir()):
406
+ claude_dir.rmdir()
407
+ console.print("[dim]Removed empty[/dim] .claude/")
408
+
409
+
410
+ # @shell_complexity: CLI entry point with confirmation prompts and multi-target removal
411
+ def uninstall(
412
+ path: Path = typer.Argument(
413
+ Path(),
414
+ help="Project path",
415
+ exists=True,
416
+ file_okay=False,
417
+ dir_okay=True,
418
+ resolve_path=True,
419
+ ),
420
+ dry_run: bool = typer.Option(
421
+ False,
422
+ "--dry-run",
423
+ "-n",
424
+ help="Show what would be removed without removing",
425
+ ),
426
+ force: bool = typer.Option(
427
+ False,
428
+ "--force",
429
+ "-f",
430
+ help="Skip confirmation prompt",
431
+ ),
432
+ ) -> None:
433
+ """Remove Invar from a project.
434
+
435
+ Safely removes Invar-generated files and configurations while
436
+ preserving user content. Uses marker-based detection.
437
+
438
+ Examples:
439
+ invar uninstall --dry-run # Preview changes
440
+ invar uninstall # Remove with confirmation
441
+ invar uninstall --force # Remove without confirmation
442
+ """
443
+ # Check if this is an Invar project
444
+ invar_toml = path / "invar.toml"
445
+ invar_md = path / "INVAR.md"
446
+ invar_dir = path / ".invar"
447
+
448
+ if not (invar_toml.exists() or invar_md.exists() or invar_dir.exists()):
449
+ console.print("[yellow]Warning:[/yellow] This doesn't appear to be an Invar project.")
450
+ console.print("No invar.toml, INVAR.md, or .invar/ directory found.")
451
+ raise typer.Exit(1)
452
+
453
+ # Collect targets
454
+ targets = collect_removal_targets(path)
455
+
456
+ # Check if there's anything to do
457
+ if not any([targets["delete_dirs"], targets["delete_files"], targets["modify_files"]]):
458
+ console.print("[green]Nothing to remove.[/green] Project is clean.")
459
+ raise typer.Exit(0)
460
+
461
+ # Show preview
462
+ show_preview(targets)
463
+
464
+ # Dry run exits here
465
+ if dry_run:
466
+ console.print("[dim]Dry run complete. No changes made.[/dim]")
467
+ raise typer.Exit(0)
468
+
469
+ # Confirmation
470
+ if not force:
471
+ confirm = typer.confirm("Proceed with uninstall?", default=False)
472
+ if not confirm:
473
+ console.print("[dim]Cancelled.[/dim]")
474
+ raise typer.Exit(0)
475
+
476
+ # Execute
477
+ execute_removal(path, targets)
478
+
479
+ console.print("\n[green]✓ Invar has been removed from the project.[/green]")
@@ -121,6 +121,7 @@ def count_contracts_in_file(
121
121
  return Success(result)
122
122
 
123
123
 
124
+ # @shell_complexity: Git status parsing requires multiple branch conditions
124
125
  def get_changed_python_files(path: Path) -> Result[list[Path], str]:
125
126
  """Get Python files changed in git."""
126
127
  try:
@@ -153,6 +154,7 @@ def get_changed_python_files(path: Path) -> Result[list[Path], str]:
153
154
  return Failure("Git not found")
154
155
 
155
156
 
157
+ # @shell_complexity: Coverage calculation with multiple file/directory handling paths
156
158
  def calculate_contract_coverage(
157
159
  path: Path, changed_only: bool = False
158
160
  ) -> Result[ContractCoverageReport, str]:
@@ -207,6 +209,7 @@ def calculate_contract_coverage(
207
209
  return Success(report)
208
210
 
209
211
 
212
+ # @shell_complexity: Batch detection with git status parsing and threshold logic
210
213
  def detect_batch_creation(
211
214
  path: Path, threshold: int = 3
212
215
  ) -> Result[BatchWarning | None, str]:
@@ -263,7 +266,7 @@ def detect_batch_creation(
263
266
  return Success(None)
264
267
 
265
268
 
266
- # @shell_orchestration: Report formatting tightly coupled with CLI output
269
+ # @shell_complexity: Report formatting with multiple conditional sections
267
270
  def format_contract_coverage_report(report: ContractCoverageReport) -> str:
268
271
  """Format coverage report for human-readable output."""
269
272
  lines = [