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 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
@@ -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)
@@ -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
- False, "--changed", help="Only check git-modified files"
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).