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.
- invar/core/utils.py +3 -1
- invar/shell/claude_hooks.py +90 -0
- invar/shell/commands/guard.py +2 -0
- invar/shell/commands/init.py +303 -384
- invar/shell/commands/uninstall.py +479 -0
- invar/shell/contract_coverage.py +4 -1
- invar/shell/templates.py +36 -99
- invar/templates/commands/audit.md +6 -0
- invar/templates/commands/guard.md +6 -0
- invar/templates/config/pre-commit.yaml.jinja +2 -0
- {invar_tools-1.6.0.dist-info → invar_tools-1.7.1.dist-info}/METADATA +55 -45
- {invar_tools-1.6.0.dist-info → invar_tools-1.7.1.dist-info}/RECORD +17 -19
- invar/templates/aider.conf.yml.template +0 -31
- invar/templates/cursorrules.template +0 -40
- invar/templates/pre-commit-config.yaml.template +0 -44
- {invar_tools-1.6.0.dist-info → invar_tools-1.7.1.dist-info}/WHEEL +0 -0
- {invar_tools-1.6.0.dist-info → invar_tools-1.7.1.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.6.0.dist-info → invar_tools-1.7.1.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.6.0.dist-info → invar_tools-1.7.1.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.6.0.dist-info → invar_tools-1.7.1.dist-info}/licenses/NOTICE +0 -0
|
@@ -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]")
|
invar/shell/contract_coverage.py
CHANGED
|
@@ -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
|
-
# @
|
|
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 = [
|