invar-tools 1.12.0__py3-none-any.whl → 1.15.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.
- invar/core/feedback.py +110 -0
- invar/shell/claude_hooks.py +49 -0
- invar/shell/commands/feedback.py +258 -0
- invar/shell/commands/guard.py +9 -1
- invar/shell/commands/init.py +118 -38
- invar/shell/pi_tools.py +120 -0
- invar/templates/claude-md/universal/tool-selection.md +110 -0
- invar/templates/config/CLAUDE.md.jinja +2 -0
- invar/templates/hooks/UserPromptSubmit.sh.jinja +31 -0
- invar/templates/hooks/pi/invar.ts.jinja +35 -1
- invar/templates/manifest.toml +8 -0
- invar/templates/pi-tools/invar/index.ts +207 -0
- invar/templates/protocol/python/tools.md +5 -2
- invar/templates/protocol/typescript/tools.md +5 -2
- invar/templates/skills/invar-reflect/CONFIG.md +480 -0
- invar/templates/skills/invar-reflect/SKILL.md +466 -0
- invar/templates/skills/invar-reflect/template.md +343 -0
- {invar_tools-1.12.0.dist-info → invar_tools-1.15.0.dist-info}/METADATA +6 -3
- {invar_tools-1.12.0.dist-info → invar_tools-1.15.0.dist-info}/RECORD +24 -16
- {invar_tools-1.12.0.dist-info → invar_tools-1.15.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.12.0.dist-info → invar_tools-1.15.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.12.0.dist-info → invar_tools-1.15.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.12.0.dist-info → invar_tools-1.15.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.12.0.dist-info → invar_tools-1.15.0.dist-info}/licenses/NOTICE +0 -0
invar/core/feedback.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Feedback anonymization logic (Core).
|
|
3
|
+
|
|
4
|
+
DX-79 Phase D: Pure string transformations for privacy protection.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from deal import post, pre
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pre(lambda content: len(content) > 0)
|
|
15
|
+
@post(lambda result: len(result) > 0)
|
|
16
|
+
def anonymize_feedback_content(content: str) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Anonymize feedback content by removing identifying information.
|
|
19
|
+
|
|
20
|
+
Removes:
|
|
21
|
+
- Project names
|
|
22
|
+
- File paths (absolute and relative)
|
|
23
|
+
- Function/symbol names
|
|
24
|
+
- Error messages
|
|
25
|
+
- Email addresses
|
|
26
|
+
- IP addresses
|
|
27
|
+
- User home directories
|
|
28
|
+
- API tokens and secrets
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
content: Original feedback content
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Anonymized content with sensitive data redacted
|
|
35
|
+
|
|
36
|
+
>>> content = "**Project**: MyApp\\nFile: /Users/alice/src/app.py"
|
|
37
|
+
>>> result = anonymize_feedback_content(content)
|
|
38
|
+
>>> "MyApp" not in result
|
|
39
|
+
True
|
|
40
|
+
>>> "[redacted]" in result
|
|
41
|
+
True
|
|
42
|
+
>>> "/Users/alice" not in result
|
|
43
|
+
True
|
|
44
|
+
|
|
45
|
+
>>> content = "Error: Invalid token abc123def456\\nEmail: user@example.com"
|
|
46
|
+
>>> result = anonymize_feedback_content(content)
|
|
47
|
+
>>> "user@example.com" not in result
|
|
48
|
+
True
|
|
49
|
+
>>> "[email redacted]" in result
|
|
50
|
+
True
|
|
51
|
+
"""
|
|
52
|
+
# Replace project name
|
|
53
|
+
content = re.sub(r"\*\*Project\*\*: .*", "**Project**: [redacted]", content)
|
|
54
|
+
|
|
55
|
+
# Replace email addresses
|
|
56
|
+
content = re.sub(
|
|
57
|
+
r"\b[\w.+-]+@[\w.-]+\.\w{2,}\b",
|
|
58
|
+
"[email redacted]",
|
|
59
|
+
content,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Replace IP addresses
|
|
63
|
+
content = re.sub(
|
|
64
|
+
r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b",
|
|
65
|
+
"[IP redacted]",
|
|
66
|
+
content,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Replace user home directories
|
|
70
|
+
content = re.sub(r"/Users/[\w.-]+/?", "[home]/", content)
|
|
71
|
+
content = re.sub(r"/home/[\w.-]+/?", "[home]/", content)
|
|
72
|
+
content = re.sub(r"C:\\Users\\[\w.-]+\\?", r"[home]\\", content)
|
|
73
|
+
|
|
74
|
+
# Replace absolute file paths (before specific replacements)
|
|
75
|
+
content = re.sub(r"/[\w/.-]+\.(py|ts|js|md|json|yaml)", "[path redacted]", content)
|
|
76
|
+
|
|
77
|
+
# Replace file paths (specific patterns)
|
|
78
|
+
content = re.sub(r"File: [^\s]+", "File: [path redacted]", content, flags=re.IGNORECASE)
|
|
79
|
+
content = re.sub(r"src/[\w/.-]+", "[path redacted]", content)
|
|
80
|
+
|
|
81
|
+
# Replace API tokens and secrets (long hex/base64 strings)
|
|
82
|
+
# Match 32+ character hex strings (likely tokens)
|
|
83
|
+
content = re.sub(r"\b[a-fA-F0-9]{32,}\b", "[token redacted]", content)
|
|
84
|
+
# Match base64-like strings (24+ chars with alphanumeric and +/=)
|
|
85
|
+
content = re.sub(r"\b[A-Za-z0-9+/]{24,}={0,2}\b", "[token redacted]", content)
|
|
86
|
+
|
|
87
|
+
# Replace function names in prose
|
|
88
|
+
content = re.sub(
|
|
89
|
+
r"function ['\"][\w_]+['\"]",
|
|
90
|
+
"function [name redacted]",
|
|
91
|
+
content,
|
|
92
|
+
flags=re.IGNORECASE,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Replace symbol references (more targeted pattern)
|
|
96
|
+
# Only replace if it looks like a function call with parentheses
|
|
97
|
+
content = re.sub(
|
|
98
|
+
r"`([\w.]+)\([^)]*\)`",
|
|
99
|
+
lambda m: "`[symbol redacted]()`" if "." in m.group(1) or "_" in m.group(1) else m.group(0),
|
|
100
|
+
content,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Replace error messages (in code blocks or after "Error:")
|
|
104
|
+
content = re.sub(
|
|
105
|
+
r"Error: .+",
|
|
106
|
+
"Error: [message redacted]",
|
|
107
|
+
content,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return content
|
invar/shell/claude_hooks.py
CHANGED
|
@@ -193,6 +193,55 @@ def _register_hooks_in_settings(project_path: Path) -> Result[bool, str]:
|
|
|
193
193
|
return Failure(f"Failed to update settings: {e}")
|
|
194
194
|
|
|
195
195
|
|
|
196
|
+
# @shell_complexity: Feedback config management in settings.local.json
|
|
197
|
+
def add_feedback_config(
|
|
198
|
+
project_path: Path,
|
|
199
|
+
enabled: bool = True,
|
|
200
|
+
console: Console | None = None,
|
|
201
|
+
) -> Result[bool, str]:
|
|
202
|
+
"""
|
|
203
|
+
Add feedback configuration to .claude/settings.local.json.
|
|
204
|
+
|
|
205
|
+
DX-79 Phase C: Init Integration for /invar-reflect skill.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
project_path: Path to project root
|
|
209
|
+
enabled: Whether to enable feedback collection (default: True)
|
|
210
|
+
console: Optional Rich console for output
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Success(True) if config added/updated, Failure with error message otherwise
|
|
214
|
+
"""
|
|
215
|
+
import json
|
|
216
|
+
|
|
217
|
+
settings_path = project_path / ".claude" / "settings.local.json"
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
|
|
221
|
+
|
|
222
|
+
# Add feedback configuration
|
|
223
|
+
existing["feedback"] = {
|
|
224
|
+
"enabled": enabled,
|
|
225
|
+
"auto_trigger": enabled, # Same as enabled
|
|
226
|
+
"retention_days": 90,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
# Ensure .claude directory exists
|
|
230
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
|
|
232
|
+
# Write with indentation for readability
|
|
233
|
+
settings_path.write_text(json.dumps(existing, indent=2) + "\n")
|
|
234
|
+
|
|
235
|
+
if console:
|
|
236
|
+
status = "enabled" if enabled else "disabled"
|
|
237
|
+
console.print(f" [green]✓[/green] Feedback collection {status}")
|
|
238
|
+
|
|
239
|
+
return Success(True)
|
|
240
|
+
|
|
241
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
242
|
+
return Failure(f"Failed to update settings: {e}")
|
|
243
|
+
|
|
244
|
+
|
|
196
245
|
# @shell_complexity: Multi-file installation with backup/merge logic for user hooks
|
|
197
246
|
def install_claude_hooks(
|
|
198
247
|
project_path: Path,
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands for feedback management.
|
|
3
|
+
|
|
4
|
+
DX-79 Phase D: Analysis Tools for /invar-reflect feedback.
|
|
5
|
+
Shell module: handles feedback file operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from returns.result import Failure, Result, Success
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from invar.core.feedback import anonymize_feedback_content
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
# Create feedback subcommand app
|
|
24
|
+
feedback_app = typer.Typer(
|
|
25
|
+
name="feedback",
|
|
26
|
+
help="Manage Invar usage feedback files.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# @shell_complexity: File discovery with filtering
|
|
32
|
+
def _get_feedback_files(
|
|
33
|
+
project_path: Path,
|
|
34
|
+
older_than_days: int | None = None,
|
|
35
|
+
) -> Result[list[tuple[Path, datetime]], str]:
|
|
36
|
+
"""
|
|
37
|
+
Get feedback files with optional age filtering.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
project_path: Path to project root
|
|
41
|
+
older_than_days: Only return files older than N days (None = all)
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Success with list of (file_path, modified_time) tuples, or Failure
|
|
45
|
+
"""
|
|
46
|
+
feedback_dir = project_path / ".invar" / "feedback"
|
|
47
|
+
|
|
48
|
+
if not feedback_dir.exists():
|
|
49
|
+
return Success([])
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
files: list[tuple[Path, datetime]] = []
|
|
53
|
+
cutoff = datetime.now() - timedelta(days=older_than_days) if older_than_days else None
|
|
54
|
+
|
|
55
|
+
for file in feedback_dir.glob("feedback-*.md"):
|
|
56
|
+
if not file.is_file():
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
mtime = datetime.fromtimestamp(file.stat().st_mtime)
|
|
60
|
+
|
|
61
|
+
if cutoff is None or mtime < cutoff:
|
|
62
|
+
files.append((file, mtime))
|
|
63
|
+
|
|
64
|
+
# Sort by modification time (newest first)
|
|
65
|
+
files.sort(key=lambda x: x[1], reverse=True)
|
|
66
|
+
return Success(files)
|
|
67
|
+
|
|
68
|
+
except OSError as e:
|
|
69
|
+
return Failure(f"Failed to read feedback directory: {e}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# @invar:allow entry_point_too_thick: CLI display with table formatting and help text
|
|
73
|
+
@feedback_app.command(name="list")
|
|
74
|
+
def list_feedback(
|
|
75
|
+
path: Annotated[
|
|
76
|
+
Path,
|
|
77
|
+
typer.Argument(help="Project root directory (default: current directory)"),
|
|
78
|
+
] = Path(),
|
|
79
|
+
) -> None:
|
|
80
|
+
"""
|
|
81
|
+
List all feedback files in the project.
|
|
82
|
+
|
|
83
|
+
Shows:
|
|
84
|
+
- File name
|
|
85
|
+
- Last modified time
|
|
86
|
+
- File size
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
invar feedback list
|
|
90
|
+
"""
|
|
91
|
+
path = path.resolve()
|
|
92
|
+
result = _get_feedback_files(path)
|
|
93
|
+
|
|
94
|
+
if isinstance(result, Failure):
|
|
95
|
+
console.print(f"[red]Error:[/red] {result.failure()}")
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
|
|
98
|
+
files = result.unwrap()
|
|
99
|
+
|
|
100
|
+
if not files:
|
|
101
|
+
console.print("[dim]No feedback files found in .invar/feedback/[/dim]")
|
|
102
|
+
console.print("\n[dim]Tip: Use /invar-reflect to generate feedback[/dim]")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Display table
|
|
106
|
+
table = Table(title="Invar Feedback Files")
|
|
107
|
+
table.add_column("File", style="cyan")
|
|
108
|
+
table.add_column("Last Modified", style="yellow")
|
|
109
|
+
table.add_column("Size", justify="right", style="dim")
|
|
110
|
+
|
|
111
|
+
for file, mtime in files:
|
|
112
|
+
size_kb = file.stat().st_size / 1024
|
|
113
|
+
table.add_row(
|
|
114
|
+
file.name,
|
|
115
|
+
mtime.strftime("%Y-%m-%d %H:%M"),
|
|
116
|
+
f"{size_kb:.1f} KB",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
console.print(table)
|
|
120
|
+
console.print(f"\n[bold]{len(files)}[/bold] feedback file(s) found")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# @invar:allow entry_point_too_thick: CLI workflow with confirmation, deletion, and error handling
|
|
124
|
+
@feedback_app.command(name="cleanup")
|
|
125
|
+
def cleanup_feedback(
|
|
126
|
+
older_than: Annotated[
|
|
127
|
+
int,
|
|
128
|
+
typer.Option("--older-than", help="Delete files older than N days"),
|
|
129
|
+
] = 90,
|
|
130
|
+
path: Annotated[
|
|
131
|
+
Path,
|
|
132
|
+
typer.Argument(help="Project root directory (default: current directory)"),
|
|
133
|
+
] = Path(),
|
|
134
|
+
dry_run: Annotated[
|
|
135
|
+
bool,
|
|
136
|
+
typer.Option("--dry-run", help="Show what would be deleted without deleting"),
|
|
137
|
+
] = False,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Clean up old feedback files.
|
|
141
|
+
|
|
142
|
+
Default: Delete files older than 90 days.
|
|
143
|
+
|
|
144
|
+
Examples:
|
|
145
|
+
invar feedback cleanup # Delete files older than 90 days
|
|
146
|
+
invar feedback cleanup --older-than 30 # Delete files older than 30 days
|
|
147
|
+
invar feedback cleanup --dry-run # Preview what would be deleted
|
|
148
|
+
"""
|
|
149
|
+
path = path.resolve()
|
|
150
|
+
result = _get_feedback_files(path, older_than_days=older_than)
|
|
151
|
+
|
|
152
|
+
if isinstance(result, Failure):
|
|
153
|
+
console.print(f"[red]Error:[/red] {result.failure()}")
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
|
|
156
|
+
files = result.unwrap()
|
|
157
|
+
|
|
158
|
+
if not files:
|
|
159
|
+
console.print(f"[green]✓[/green] No files older than {older_than} days")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
# Display what will be deleted
|
|
163
|
+
console.print(f"[bold]Files older than {older_than} days:[/bold]\n")
|
|
164
|
+
for file, mtime in files:
|
|
165
|
+
age_days = (datetime.now() - mtime).days
|
|
166
|
+
console.print(f" [red]✗[/red] {file.name} ({age_days} days old)")
|
|
167
|
+
|
|
168
|
+
if dry_run:
|
|
169
|
+
console.print(f"\n[dim]Dry run: Would delete {len(files)} file(s)[/dim]")
|
|
170
|
+
console.print("[dim]Run without --dry-run to apply[/dim]")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Confirm deletion
|
|
174
|
+
from rich.prompt import Confirm
|
|
175
|
+
|
|
176
|
+
if not Confirm.ask(f"\nDelete {len(files)} file(s)?", default=False):
|
|
177
|
+
console.print("[yellow]Cancelled[/yellow]")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
# Delete files
|
|
181
|
+
deleted = 0
|
|
182
|
+
failed = 0
|
|
183
|
+
for file, _ in files:
|
|
184
|
+
try:
|
|
185
|
+
file.unlink()
|
|
186
|
+
deleted += 1
|
|
187
|
+
except OSError as e:
|
|
188
|
+
console.print(f" [red]Failed to delete {file.name}:[/red] {e}")
|
|
189
|
+
failed += 1
|
|
190
|
+
|
|
191
|
+
# Summary
|
|
192
|
+
console.print(f"\n[green]✓[/green] Deleted {deleted} file(s)")
|
|
193
|
+
if failed > 0:
|
|
194
|
+
console.print(f"[red]✗[/red] Failed to delete {failed} file(s)")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# @invar:allow entry_point_too_thick: CLI with file handling, error display, and multiple output modes
|
|
198
|
+
@feedback_app.command(name="anonymize")
|
|
199
|
+
def anonymize_feedback(
|
|
200
|
+
file: Annotated[
|
|
201
|
+
str,
|
|
202
|
+
typer.Argument(help="Feedback file name (e.g., feedback-2026-01-03.md)"),
|
|
203
|
+
],
|
|
204
|
+
path: Annotated[
|
|
205
|
+
Path,
|
|
206
|
+
typer.Option("--path", help="Project root directory"),
|
|
207
|
+
] = Path(),
|
|
208
|
+
output: Annotated[
|
|
209
|
+
Path | None,
|
|
210
|
+
typer.Option("--output", "-o", help="Output file (default: stdout)"),
|
|
211
|
+
] = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""
|
|
214
|
+
Anonymize feedback for sharing.
|
|
215
|
+
|
|
216
|
+
Removes:
|
|
217
|
+
- Project names
|
|
218
|
+
- File paths
|
|
219
|
+
- Function/symbol names
|
|
220
|
+
- Error messages
|
|
221
|
+
|
|
222
|
+
Examples:
|
|
223
|
+
invar feedback anonymize feedback-2026-01-03.md
|
|
224
|
+
invar feedback anonymize feedback-2026-01-03.md -o safe.md
|
|
225
|
+
invar feedback anonymize feedback-2026-01-03.md > share.md
|
|
226
|
+
"""
|
|
227
|
+
path = path.resolve()
|
|
228
|
+
feedback_dir = path / ".invar" / "feedback"
|
|
229
|
+
input_file = feedback_dir / file
|
|
230
|
+
|
|
231
|
+
if not input_file.exists():
|
|
232
|
+
console.print(f"[red]Error:[/red] File not found: {input_file}")
|
|
233
|
+
console.print("\n[dim]Available files:[/dim]")
|
|
234
|
+
|
|
235
|
+
# Show available files
|
|
236
|
+
result = _get_feedback_files(path)
|
|
237
|
+
if isinstance(result, Success):
|
|
238
|
+
for f, _ in result.unwrap():
|
|
239
|
+
console.print(f" {f.name}")
|
|
240
|
+
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
|
|
243
|
+
# Read and anonymize
|
|
244
|
+
try:
|
|
245
|
+
content = input_file.read_text(encoding="utf-8")
|
|
246
|
+
anonymized = anonymize_feedback_content(content)
|
|
247
|
+
|
|
248
|
+
# Output
|
|
249
|
+
if output:
|
|
250
|
+
output.write_text(anonymized, encoding="utf-8")
|
|
251
|
+
console.print(f"[green]✓[/green] Anonymized feedback saved to: {output}")
|
|
252
|
+
else:
|
|
253
|
+
# Print to stdout
|
|
254
|
+
console.print(anonymized)
|
|
255
|
+
|
|
256
|
+
except OSError as e:
|
|
257
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
258
|
+
raise typer.Exit(1)
|
invar/shell/commands/guard.py
CHANGED
|
@@ -41,6 +41,11 @@ from invar.shell.commands.doc import doc_app
|
|
|
41
41
|
|
|
42
42
|
app.add_typer(doc_app, name="doc")
|
|
43
43
|
|
|
44
|
+
# DX-79: Register feedback subcommand
|
|
45
|
+
from invar.shell.commands.feedback import feedback_app
|
|
46
|
+
|
|
47
|
+
app.add_typer(feedback_app, name="feedback")
|
|
48
|
+
|
|
44
49
|
|
|
45
50
|
# @shell_orchestration: Statistics helper for CLI guard output
|
|
46
51
|
# @shell_complexity: Iterates symbols checking kind and contracts (4 branches minimal)
|
|
@@ -122,7 +127,7 @@ def guard(
|
|
|
122
127
|
),
|
|
123
128
|
strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors"),
|
|
124
129
|
changed: bool = typer.Option(
|
|
125
|
-
|
|
130
|
+
True, "--changed/--all", help="Check git-modified files only (use --all for full check)"
|
|
126
131
|
),
|
|
127
132
|
static: bool = typer.Option(
|
|
128
133
|
False, "--static", help="Static analysis only, skip all runtime tests"
|
|
@@ -159,6 +164,9 @@ def guard(
|
|
|
159
164
|
"""Check project against Invar architecture rules.
|
|
160
165
|
|
|
161
166
|
Smart Guard: Runs static analysis + doctests + CrossHair + Hypothesis by default.
|
|
167
|
+
|
|
168
|
+
By default, checks only git-modified files for fast feedback during development.
|
|
169
|
+
Use --all to check the entire project (useful for CI/release).
|
|
162
170
|
Use --static for quick static-only checks (~0.5s vs ~5s full).
|
|
163
171
|
Use --suggest to get functional pattern suggestions (NewType, Validation, etc.).
|
|
164
172
|
Use --contracts-only (-c) to check contract coverage without running tests (DX-63).
|