invar-tools 1.7.0__py3-none-any.whl → 1.8.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.
@@ -0,0 +1,32 @@
1
+ """
2
+ Template transformation helpers.
3
+
4
+ Core module: pure logic for template content transformations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from deal import post
10
+
11
+
12
+ @post(lambda result: "`" not in result or "\\`" in result)
13
+ @post(lambda result: "${" not in result or "\\${" in result)
14
+ def escape_for_js_template(content: str) -> str:
15
+ """
16
+ Escape content for JavaScript template literal.
17
+
18
+ Escapes backticks and ${} sequences that would be interpreted
19
+ by JavaScript template literals.
20
+
21
+ >>> escape_for_js_template("Hello `world`")
22
+ 'Hello \\\\`world\\\\`'
23
+ >>> escape_for_js_template("Value: ${x}")
24
+ 'Value: \\\\${x}'
25
+ >>> escape_for_js_template("Normal text")
26
+ 'Normal text'
27
+ """
28
+ # Escape backticks
29
+ content = content.replace("`", "\\`")
30
+ # Escape ${} template expressions
31
+ content = content.replace("${", "\\${")
32
+ return content
invar/core/utils.py CHANGED
@@ -83,7 +83,7 @@ def get_combined_status(
83
83
  return "passed"
84
84
 
85
85
 
86
- @pre(lambda data, source: source in ("pyproject", "invar", "default"))
86
+ @pre(lambda data, source: source in ("pyproject", "invar", "invar_dir", "default"))
87
87
  @post(lambda result: isinstance(result, dict))
88
88
  def extract_guard_section(data: dict[str, Any], source: str) -> dict[str, Any]:
89
89
  """
@@ -94,6 +94,8 @@ def extract_guard_section(data: dict[str, Any], source: str) -> dict[str, Any]:
94
94
  {'x': 1}
95
95
  >>> extract_guard_section({"guard": {"y": 2}}, "invar")
96
96
  {'y': 2}
97
+ >>> extract_guard_section({"guard": {"z": 3}}, "invar_dir")
98
+ {'z': 3}
97
99
  >>> extract_guard_section({}, "default")
98
100
  {}
99
101
  >>> extract_guard_section({"guard": 0}, "invar") # Non-dict value returns empty
@@ -29,6 +29,26 @@ HOOKS_SUBDIR = ".claude/hooks"
29
29
  DISABLED_MARKER = ".invar_disabled"
30
30
 
31
31
 
32
+ # Marker for identifying Invar hooks in settings
33
+ INVAR_HOOK_MARKER = ".claude/hooks/"
34
+
35
+
36
+ # @shell_orchestration: Tightly coupled to Claude Code settings.local.json format
37
+ def is_invar_hook(hook_entry: dict) -> bool:
38
+ """Check if a hook entry is an Invar hook.
39
+
40
+ Works with both new format ({"hooks": [...]}) and legacy format.
41
+ """
42
+ # All hook types now use {"hooks": [...]} format
43
+ if "hooks" in hook_entry:
44
+ return any(
45
+ INVAR_HOOK_MARKER in h.get("command", "")
46
+ for h in hook_entry.get("hooks", [])
47
+ )
48
+ # Legacy format fallback: {"type": "command", "command": "..."}
49
+ return INVAR_HOOK_MARKER in hook_entry.get("command", "")
50
+
51
+
32
52
  def get_templates_path() -> Path:
33
53
  """Get the path to hook templates."""
34
54
  return Path(__file__).parent.parent / "templates" / "hooks"
@@ -100,6 +120,68 @@ def generate_hook_content(
100
120
 
101
121
 
102
122
  # @shell_complexity: Hook installation with user hook merging
123
+
124
+ def _register_hooks_in_settings(project_path: Path) -> Result[bool, str]:
125
+ """
126
+ Register hooks in .claude/settings.local.json.
127
+
128
+ Claude Code requires explicit hook registration - hooks are NOT auto-discovered
129
+ from the .claude/hooks/ directory.
130
+
131
+ Uses merge strategy:
132
+ - Preserves user's existing hooks
133
+ - Only adds/updates Invar hooks (identified by .claude/hooks/ path)
134
+ """
135
+ import json
136
+
137
+ settings_path = project_path / ".claude" / "settings.local.json"
138
+
139
+ def build_invar_hook(hook_type: str) -> dict:
140
+ """Build Invar hook entry for a hook type."""
141
+ hook_cmd = {
142
+ "type": "command",
143
+ "command": f".claude/hooks/{hook_type}.sh",
144
+ }
145
+ if hook_type in ("PreToolUse", "PostToolUse"):
146
+ # These need a matcher - use "*" to match all tools
147
+ return {
148
+ "matcher": "*",
149
+ "hooks": [hook_cmd],
150
+ }
151
+ # UserPromptSubmit, Stop don't use matchers but still need hooks wrapper
152
+ return {
153
+ "hooks": [hook_cmd],
154
+ }
155
+
156
+ try:
157
+ existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
158
+
159
+ # Get existing hooks or create empty dict
160
+ existing_hooks = existing.get("hooks", {})
161
+
162
+ # Merge each hook type
163
+ for hook_type in HOOK_TYPES:
164
+ existing_list = existing_hooks.get(hook_type, [])
165
+
166
+ # Filter out old Invar hooks, keep user hooks
167
+ user_hooks = [h for h in existing_list if not is_invar_hook(h)]
168
+
169
+ # Append new Invar hook
170
+ user_hooks.append(build_invar_hook(hook_type))
171
+
172
+ existing_hooks[hook_type] = user_hooks
173
+
174
+ existing["hooks"] = existing_hooks
175
+
176
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
177
+ settings_path.write_text(json.dumps(existing, indent=2))
178
+ return Success(True)
179
+
180
+ except (OSError, json.JSONDecodeError) as e:
181
+ return Failure(f"Failed to update settings: {e}")
182
+
183
+
184
+ # @shell_complexity: Multi-file installation with backup/merge logic for user hooks
103
185
  def install_claude_hooks(
104
186
  project_path: Path,
105
187
  console: Console,
@@ -189,9 +271,17 @@ source "$(dirname "$0")/invar.{hook_type}.sh" "$@"
189
271
  installed.append(hook_type)
190
272
 
191
273
  if installed:
274
+ # Register hooks in settings.local.json (Claude Code requires explicit registration)
275
+ reg_result = _register_hooks_in_settings(project_path)
276
+ if isinstance(reg_result, Failure):
277
+ console.print(f" [yellow]Warning:[/yellow] {reg_result.failure()}")
278
+ else:
279
+ console.print(" [green]Registered[/green] hooks in .claude/settings.local.json")
280
+
192
281
  console.print("\n [bold green]✓ Claude Code hooks installed[/bold green]")
193
282
  console.print(" [dim]Auto-escape: pytest --pdb, pytest --cov, vendor/[/dim]")
194
283
  console.print(" [dim]Manual escape: INVAR_ALLOW_PYTEST=1[/dim]")
284
+ console.print(" [yellow]⚠ Restart Claude Code session for hooks to take effect[/yellow]")
195
285
 
196
286
  if failed:
197
287
  return Failure(f"Failed to install hooks: {', '.join(failed)}")