invar-tools 1.2.0__py3-none-any.whl → 1.3.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.
Files changed (89) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +10 -10
  3. invar/core/entry_points.py +105 -32
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +1 -2
  6. invar/core/formatter.py +6 -7
  7. invar/core/hypothesis_strategies.py +5 -7
  8. invar/core/inspect.py +1 -1
  9. invar/core/lambda_helpers.py +3 -3
  10. invar/core/models.py +7 -1
  11. invar/core/must_use.py +2 -1
  12. invar/core/parser.py +7 -4
  13. invar/core/postcondition_scope.py +128 -0
  14. invar/core/property_gen.py +8 -5
  15. invar/core/purity.py +3 -3
  16. invar/core/purity_heuristics.py +5 -9
  17. invar/core/references.py +8 -6
  18. invar/core/review_trigger.py +78 -6
  19. invar/core/rule_meta.py +8 -0
  20. invar/core/rules.py +18 -19
  21. invar/core/shell_analysis.py +5 -10
  22. invar/core/shell_architecture.py +2 -2
  23. invar/core/strategies.py +7 -14
  24. invar/core/suggestions.py +86 -0
  25. invar/core/sync_helpers.py +238 -0
  26. invar/core/tautology.py +102 -37
  27. invar/core/template_parser.py +467 -0
  28. invar/core/timeout_inference.py +4 -7
  29. invar/core/utils.py +13 -15
  30. invar/core/verification_routing.py +4 -7
  31. invar/mcp/server.py +100 -17
  32. invar/shell/commands/__init__.py +11 -0
  33. invar/shell/{cli.py → commands/guard.py} +94 -14
  34. invar/shell/{init_cmd.py → commands/init.py} +179 -27
  35. invar/shell/commands/merge.py +256 -0
  36. invar/shell/commands/sync_self.py +113 -0
  37. invar/shell/commands/template_sync.py +366 -0
  38. invar/shell/commands/update.py +48 -0
  39. invar/shell/config.py +12 -24
  40. invar/shell/coverage.py +351 -0
  41. invar/shell/guard_helpers.py +38 -17
  42. invar/shell/guard_output.py +7 -1
  43. invar/shell/property_tests.py +58 -22
  44. invar/shell/prove/__init__.py +9 -0
  45. invar/shell/{prove.py → prove/crosshair.py} +40 -33
  46. invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
  47. invar/shell/subprocess_env.py +393 -0
  48. invar/shell/template_engine.py +345 -0
  49. invar/shell/templates.py +19 -0
  50. invar/shell/testing.py +71 -20
  51. invar/templates/CLAUDE.md.template +38 -17
  52. invar/templates/aider.conf.yml.template +2 -2
  53. invar/templates/commands/{review.md → audit.md} +20 -82
  54. invar/templates/commands/guard.md +77 -0
  55. invar/templates/config/CLAUDE.md.jinja +206 -0
  56. invar/templates/config/context.md.jinja +92 -0
  57. invar/templates/config/pre-commit.yaml.jinja +44 -0
  58. invar/templates/context.md.template +33 -0
  59. invar/templates/cursorrules.template +7 -4
  60. invar/templates/examples/README.md +2 -0
  61. invar/templates/examples/conftest.py +3 -0
  62. invar/templates/examples/contracts.py +5 -5
  63. invar/templates/examples/core_shell.py +11 -7
  64. invar/templates/examples/workflow.md +81 -0
  65. invar/templates/manifest.toml +137 -0
  66. invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
  67. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  68. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  69. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  70. invar/templates/skills/review/SKILL.md.jinja +125 -0
  71. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
  72. invar_tools-1.3.0.dist-info/RECORD +95 -0
  73. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  74. invar/contracts.py +0 -152
  75. invar/decorators.py +0 -94
  76. invar/invariant.py +0 -58
  77. invar/resource.py +0 -99
  78. invar/shell/update_cmd.py +0 -193
  79. invar_tools-1.2.0.dist-info/RECORD +0 -77
  80. invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
  81. /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
  82. /invar/shell/{perception.py → commands/perception.py} +0 -0
  83. /invar/shell/{test_cmd.py → commands/test.py} +0 -0
  84. /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
  85. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  86. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
  87. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
  88. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
  89. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +0 -0
@@ -3,6 +3,8 @@ Init command for Invar.
3
3
 
4
4
  Shell module: handles project initialization.
5
5
  DX-21B: Added --claude flag for Claude Code integration.
6
+ DX-55: Unified idempotent init command with smart merge.
7
+ DX-56: Uses unified template sync engine for file generation.
6
8
  """
7
9
 
8
10
  from __future__ import annotations
@@ -15,18 +17,23 @@ import typer
15
17
  from returns.result import Failure, Success
16
18
  from rich.console import Console
17
19
 
20
+ from invar.core.sync_helpers import SyncConfig
21
+ from invar.core.template_parser import ClaudeMdState
22
+ from invar.shell.commands.merge import (
23
+ ProjectState,
24
+ detect_project_state,
25
+ )
26
+ from invar.shell.commands.template_sync import sync_templates
18
27
  from invar.shell.mcp_config import (
19
28
  detect_available_methods,
20
29
  generate_mcp_json,
21
30
  get_method_by_name,
22
31
  get_recommended_method,
23
32
  )
33
+ from invar.shell.template_engine import generate_from_manifest
24
34
  from invar.shell.templates import (
25
35
  add_config,
26
36
  add_invar_reference,
27
- copy_commands_directory,
28
- copy_examples_directory,
29
- copy_template,
30
37
  create_agent_config,
31
38
  create_directories,
32
39
  detect_agent_configs,
@@ -104,10 +111,10 @@ def append_invar_reference_to_claude_md(path: Path) -> bool:
104
111
  Your first message MUST display:
105
112
 
106
113
  ```
107
- ✓ Check-In: guard PASS | top: <entry1>, <entry2>
114
+ ✓ Check-In: [project] | [branch] | [clean/dirty]
108
115
  ```
109
116
 
110
- Execute `invar guard --changed` and `invar map --top 10`, then show this one-line summary.
117
+ Read `.invar/context.md` first. Do NOT run guard/map at Check-In.
111
118
 
112
119
  ### Final
113
120
 
@@ -194,12 +201,32 @@ def init(
194
201
  hooks: bool = typer.Option(
195
202
  True, "--hooks/--no-hooks", help="Install pre-commit hooks (default: ON)"
196
203
  ),
204
+ skills: bool = typer.Option(
205
+ True, "--skills/--no-skills", help="Create .claude/skills/ (default: ON, use --no-skills for Cursor)"
206
+ ),
197
207
  yes: bool = typer.Option(
198
208
  False, "--yes", "-y", help="Accept defaults without prompting"
199
209
  ),
210
+ check: bool = typer.Option(
211
+ False, "--check", help="Preview changes without applying (DX-55)"
212
+ ),
213
+ force: bool = typer.Option(
214
+ False, "--force", help="Update even if already current (DX-55)"
215
+ ),
216
+ reset: bool = typer.Option(
217
+ False, "--reset", help="Dangerous: discard all user content (DX-55)"
218
+ ),
200
219
  ) -> None:
201
220
  """
202
- Initialize Invar configuration in a project.
221
+ Initialize or update Invar configuration (idempotent).
222
+
223
+ DX-55: This command is idempotent - safe to run multiple times.
224
+ It detects current state and does the right thing:
225
+
226
+ \b
227
+ - New project: Full setup
228
+ - Existing project: Update managed regions, preserve user content
229
+ - Corrupted/overwritten: Smart recovery with content preservation
203
230
 
204
231
  Works with or without pyproject.toml:
205
232
 
@@ -207,13 +234,74 @@ def init(
207
234
  - If pyproject.toml exists: adds tool.invar section
208
235
  - Otherwise: creates invar.toml
209
236
 
210
- Use --claude to run 'claude /init' first (recommended for Claude Code users).
237
+ Use --check to preview changes without applying.
238
+ Use --force to update even if already current.
239
+ Use --reset to discard all user content (dangerous).
240
+ Use --claude to run 'claude /init' first.
211
241
  Use --mcp-method to specify MCP execution method (uvx, command, python).
212
242
  Use --dirs to always create directories, --no-dirs to skip.
213
243
  Use --no-hooks to skip pre-commit hooks installation.
244
+ Use --no-skills to skip .claude/skills/ creation (for Cursor users).
214
245
  Use --yes to accept defaults without prompting.
215
246
  """
216
- # DX-21B: Run claude /init if requested
247
+ from invar import __version__
248
+
249
+ # DX-55: Detect project state first
250
+ state = detect_project_state(path)
251
+
252
+ # --check mode: preview only
253
+ if check:
254
+ _show_check_preview(state, path, __version__)
255
+ return
256
+
257
+ # --reset mode: dangerous full reset
258
+ if reset:
259
+ if not yes and not typer.confirm(
260
+ "[red]This will DELETE all user customizations. Continue?[/red]",
261
+ default=False,
262
+ ):
263
+ console.print("[yellow]Cancelled[/yellow]")
264
+ return
265
+ # Fall through to full init with reset flag
266
+ state = ProjectState(
267
+ initialized=False,
268
+ claude_md_state=ClaudeMdState(state="absent"),
269
+ version="",
270
+ needs_update=True,
271
+ )
272
+
273
+ # DX-55: Handle based on detected state
274
+ action = state.action if not force else "update"
275
+
276
+ if action == "none" and not force:
277
+ # DX-55: Check for missing required files before declaring "no changes needed"
278
+ missing_files = []
279
+ if skills:
280
+ skill_files = [
281
+ ".claude/skills/develop/SKILL.md",
282
+ ".claude/skills/investigate/SKILL.md",
283
+ ".claude/skills/propose/SKILL.md",
284
+ ".claude/skills/review/SKILL.md",
285
+ ]
286
+ for skill_file in skill_files:
287
+ if not (path / skill_file).exists():
288
+ missing_files.append(skill_file)
289
+
290
+ if not missing_files:
291
+ console.print(f"[green]✓[/green] Invar v{__version__} configured (no changes needed)")
292
+ console.print("[dim]Use --force to refresh managed regions[/dim]")
293
+ return
294
+ else:
295
+ # Recreate missing files
296
+ console.print(f"[yellow]Detected:[/yellow] {len(missing_files)} missing file(s)")
297
+ result = generate_from_manifest(path, syntax="cli", files_to_generate=missing_files)
298
+ if isinstance(result, Success):
299
+ for generated_file in result.unwrap():
300
+ console.print(f"[green]Restored[/green] {generated_file}")
301
+ console.print(f"[green]✓[/green] Invar v{__version__} configured")
302
+ return
303
+
304
+ # DX-21B: Run claude /init if requested (before sync)
217
305
  if claude:
218
306
  claude_success = run_claude_init(path)
219
307
  if claude_success:
@@ -226,26 +314,49 @@ def init(
226
314
  raise typer.Exit(1)
227
315
  config_added = config_result.unwrap()
228
316
 
229
- # Create INVAR.md (protocol)
230
- result = copy_template("INVAR.md", path)
231
- if isinstance(result, Success) and result.unwrap():
232
- console.print("[green]Created[/green] INVAR.md (Invar Protocol)")
233
-
234
- # Copy examples directory
235
- copy_examples_directory(path, console)
236
-
237
- # Create .invar directory structure
317
+ # DX-56: Use unified sync engine for file generation
318
+ console.print("\n[bold]Creating Invar files...[/bold]")
319
+
320
+ # Check for project-additions.md
321
+ has_project_additions = (path / ".invar" / "project-additions.md").exists()
322
+
323
+ # Build skip patterns for --no-skills
324
+ skip_patterns: list[str] = []
325
+ if not skills:
326
+ skip_patterns.append(".claude/skills/*")
327
+
328
+ sync_config = SyncConfig(
329
+ syntax="cli",
330
+ inject_project_additions=has_project_additions,
331
+ force=force,
332
+ check=False, # Already handled above
333
+ reset=reset,
334
+ skip_patterns=skip_patterns,
335
+ )
336
+
337
+ # DX-56: Run unified sync engine (handles DX-55 state detection internally)
338
+ result = sync_templates(path, sync_config)
339
+ if isinstance(result, Failure):
340
+ console.print(f"[yellow]Warning:[/yellow] {result.failure()}")
341
+ else:
342
+ report = result.unwrap()
343
+ for file in report.created:
344
+ console.print(f"[green]Created[/green] {file}")
345
+ for file in report.updated:
346
+ console.print(f"[cyan]Updated[/cyan] {file}")
347
+ for error in report.errors:
348
+ console.print(f"[yellow]Warning:[/yellow] {error}")
349
+
350
+ # Create .invar directory structure (for proposals template - not in manifest)
238
351
  invar_dir = path / ".invar"
239
352
  if not invar_dir.exists():
240
353
  invar_dir.mkdir()
241
- result = copy_template("context.md.template", invar_dir, "context.md")
242
- if isinstance(result, Success) and result.unwrap():
243
- console.print("[green]Created[/green] .invar/context.md (context management)")
244
354
 
245
355
  # Create proposals directory for protocol governance
246
356
  proposals_dir = invar_dir / "proposals"
247
357
  if not proposals_dir.exists():
248
358
  proposals_dir.mkdir()
359
+ from invar.shell.templates import copy_template
249
360
  result = copy_template("proposal.md.template", proposals_dir, "TEMPLATE.md")
250
361
  if isinstance(result, Success) and result.unwrap():
251
362
  console.print("[green]Created[/green] .invar/proposals/TEMPLATE.md")
@@ -273,9 +384,6 @@ def init(
273
384
  # Create full template with workflow enforcement (DX-17)
274
385
  create_agent_config(path, agent, console)
275
386
 
276
- # Copy Claude commands (DX-32: /review skill with Mode Detection)
277
- copy_commands_directory(path, console)
278
-
279
387
  # Configure MCP server (DX-16, DX-21B)
280
388
  configure_mcp_with_method(path, mcp_method)
281
389
 
@@ -301,9 +409,53 @@ def init(
301
409
  if not config_added and not (path / "INVAR.md").exists():
302
410
  console.print("[yellow]Invar already configured.[/yellow]")
303
411
 
304
- # Summary
305
- console.print("\n[bold green]Invar initialized successfully![/bold green]")
412
+ # DX-55: Summary based on action taken
413
+ if action == "full_init":
414
+ console.print(f"\n[bold green]✓ Initialized Invar v{__version__}[/bold green]")
415
+ console.print("[dim]Note: If you run 'claude /init' later, just run 'invar init' again.[/dim]")
416
+ elif action == "recover":
417
+ console.print(f"\n[bold green]✓ Recovered Invar v{__version__}[/bold green]")
418
+ console.print("[dim]Review the merged content in CLAUDE.md[/dim]")
419
+ elif action == "update" or force:
420
+ console.print(f"\n[bold green]✓ Updated Invar v{__version__}[/bold green]")
421
+ console.print("[dim]Refreshed managed regions, preserved user content[/dim]")
422
+ else:
423
+ console.print("\n[bold green]Invar initialized successfully![/bold green]")
424
+
306
425
  if claude:
307
426
  console.print("[dim]Next: Review CLAUDE.md and start coding with Claude Code[/dim]")
308
- else:
309
- console.print("[dim]Tip: Use --claude for Claude Code integration[/dim]")
427
+
428
+
429
+ # @shell_complexity: Preview display requires multiple state-specific branches
430
+ def _show_check_preview(state: ProjectState, path: Path, version: str) -> None:
431
+ """Show preview of what would change (--check mode)."""
432
+ console.print(f"\n[bold]Invar v{version} - Preview Mode[/bold]\n")
433
+
434
+ console.print(f"Project state: [cyan]{state.claude_md_state.state}[/cyan]")
435
+ console.print(f"Initialized: [cyan]{state.initialized}[/cyan]")
436
+ console.print(f"Current version: [cyan]{state.version or 'N/A'}[/cyan]")
437
+ console.print(f"Needs update: [cyan]{state.needs_update}[/cyan]")
438
+ console.print(f"Action: [cyan]{state.action}[/cyan]\n")
439
+
440
+ match state.action:
441
+ case "none":
442
+ console.print("[green]No changes needed[/green]")
443
+ case "full_init":
444
+ console.print("Would create:")
445
+ console.print(" - INVAR.md")
446
+ console.print(" - CLAUDE.md")
447
+ console.print(" - .invar/context.md")
448
+ console.print(" - .claude/skills/")
449
+ console.print(" - .pre-commit-config.yaml")
450
+ case "update":
451
+ console.print("Would update:")
452
+ console.print(f" - CLAUDE.md (managed section v{state.version} → v{version})")
453
+ console.print(" - .claude/skills/* (refresh)")
454
+ case "recover":
455
+ console.print("[yellow]Would recover:[/yellow]")
456
+ console.print(" - CLAUDE.md (restore regions, preserve content)")
457
+ case "create":
458
+ console.print("Would create:")
459
+ console.print(" - CLAUDE.md")
460
+
461
+ console.print("\n[dim]Run 'invar init' to apply.[/dim]")
@@ -0,0 +1,256 @@
1
+ """
2
+ DX-55: Smart merge logic for CLAUDE.md recovery.
3
+
4
+ Shell module: handles merging and recovering CLAUDE.md content.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import date
11
+ from typing import TYPE_CHECKING, Literal
12
+
13
+ from returns.result import Failure, Result, Success
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+ from invar.core.template_parser import (
19
+ ClaudeMdState,
20
+ detect_claude_md_state,
21
+ format_preserved_content,
22
+ parse_invar_regions,
23
+ reconstruct_file,
24
+ strip_invar_markers,
25
+ )
26
+ from invar.shell.template_engine import generate_from_manifest
27
+
28
+ # =============================================================================
29
+ # DX-55: Project State Detection
30
+ # =============================================================================
31
+
32
+
33
+ @dataclass
34
+ class ProjectState:
35
+ """Overall project initialization state.
36
+
37
+ DX-55: Captures full state for idempotent init decision.
38
+ """
39
+
40
+ initialized: bool
41
+ claude_md_state: ClaudeMdState
42
+ version: str
43
+ needs_update: bool
44
+
45
+ @property
46
+ def action(self) -> Literal["full_init", "update", "recover", "create", "none"]:
47
+ """Determine what action to take."""
48
+ if not self.initialized:
49
+ return "full_init"
50
+
51
+ match self.claude_md_state.state:
52
+ case "intact":
53
+ return "update" if self.needs_update else "none"
54
+ case "partial" | "missing":
55
+ return "recover"
56
+ case "absent":
57
+ return "create"
58
+ case _:
59
+ return "none"
60
+
61
+
62
+ # @shell_complexity: State detection requires multiple file existence checks
63
+ def detect_project_state(path: Path) -> ProjectState:
64
+ """Detect Invar initialization state.
65
+
66
+ DX-55: Core state detection for idempotent init.
67
+ """
68
+ from invar import __protocol_version__
69
+
70
+ invar_md = path / "INVAR.md"
71
+ invar_dir = path / ".invar"
72
+ claude_md = path / "CLAUDE.md"
73
+
74
+ initialized = invar_md.exists() and invar_dir.exists()
75
+
76
+ # Detect CLAUDE.md state
77
+ if claude_md.exists():
78
+ try:
79
+ content = claude_md.read_text()
80
+ claude_state = detect_claude_md_state(content)
81
+ except UnicodeDecodeError:
82
+ # Binary or non-UTF-8 content - treat as corrupt, will be replaced
83
+ claude_state = ClaudeMdState(state="partial")
84
+ else:
85
+ claude_state = ClaudeMdState(state="absent")
86
+
87
+ # Extract protocol version from existing INVAR.md
88
+ version = ""
89
+ if invar_md.exists():
90
+ content = invar_md.read_text()
91
+ import re
92
+
93
+ match = re.search(r"Invar (?:Protocol )?v([\d.]+)", content)
94
+ if match:
95
+ version = match.group(1)
96
+
97
+ # Check if update needed (compare protocol versions, not package versions)
98
+ needs_update = initialized and version != __protocol_version__
99
+
100
+ return ProjectState(
101
+ initialized=initialized,
102
+ claude_md_state=claude_state,
103
+ version=version,
104
+ needs_update=needs_update,
105
+ )
106
+
107
+
108
+ # =============================================================================
109
+ # DX-55: Smart Merge Functions
110
+ # =============================================================================
111
+
112
+
113
+ # @shell_complexity: Smart merge with multiple state handling paths
114
+ def merge_claude_md(path: Path, state: ClaudeMdState) -> Result[str, str]:
115
+ """Smart merge CLAUDE.md based on detected state.
116
+
117
+ DX-55: Preserves user content while restoring Invar regions.
118
+ """
119
+ from pathlib import Path as PathLib # Runtime import for Path operations
120
+
121
+ claude_md = PathLib(path) / "CLAUDE.md"
122
+
123
+ # Read existing content (handle binary/corrupt files)
124
+ existing_content = ""
125
+ if claude_md.exists():
126
+ try:
127
+ existing_content = claude_md.read_text()
128
+ except UnicodeDecodeError:
129
+ # Binary content - delete and recreate
130
+ claude_md.unlink()
131
+ state = ClaudeMdState(state="absent")
132
+
133
+ match state.state:
134
+ case "intact":
135
+ # Just update managed region, preserve user exactly
136
+ return _update_managed_only(path, existing_content)
137
+
138
+ case "partial":
139
+ # Corruption: try to salvage user content
140
+ return _recover_from_partial(path, existing_content, state)
141
+
142
+ case "missing":
143
+ # No Invar markers - treat entire file as user content
144
+ return _merge_with_preserved(path, existing_content)
145
+
146
+ case "absent":
147
+ # Just create new file
148
+ return Success("create_new")
149
+
150
+ return Success("no_action")
151
+
152
+
153
+ # @shell_complexity: Template regeneration with region extraction
154
+ def _update_managed_only(path: Path, existing_content: str) -> Result[str, str]:
155
+ """Update only the managed region, preserve user content."""
156
+ # Parse existing
157
+ parsed = parse_invar_regions(existing_content)
158
+
159
+ if "user" not in parsed.regions:
160
+ return Failure("No user region found")
161
+
162
+ # Generate fresh template
163
+ template_result = generate_from_manifest(
164
+ path, syntax="cli", files_to_generate=["CLAUDE.md"]
165
+ )
166
+ if isinstance(template_result, Failure):
167
+ return template_result
168
+
169
+ # Re-read and extract managed
170
+ new_content = (path / "CLAUDE.md").read_text()
171
+ new_parsed = parse_invar_regions(new_content)
172
+
173
+ if "managed" not in new_parsed.regions:
174
+ return Failure("Template missing managed region")
175
+
176
+ # Reconstruct with new managed but old user
177
+ updates = {"managed": new_parsed.regions["managed"].content}
178
+ final_content = reconstruct_file(parsed, updates)
179
+
180
+ (path / "CLAUDE.md").write_text(final_content)
181
+ return Success("updated_managed")
182
+
183
+
184
+ # @shell_complexity: Recovery with content salvage logic
185
+ def _recover_from_partial(
186
+ path: Path, existing_content: str, state: ClaudeMdState
187
+ ) -> Result[str, str]:
188
+ """Recover from partial corruption."""
189
+ from pathlib import Path as PathLib # Runtime import for Path operations
190
+
191
+ # Try to salvage user content
192
+ if state.user_content:
193
+ user_content = state.user_content
194
+ else:
195
+ # Strip markers and treat rest as user content
196
+ user_content = strip_invar_markers(existing_content)
197
+ if user_content:
198
+ user_content = format_preserved_content(
199
+ user_content, date.today().isoformat()
200
+ )
201
+
202
+ # Remove existing CLAUDE.md so generate_from_manifest creates fresh template
203
+ claude_md = PathLib(path) / "CLAUDE.md"
204
+ if claude_md.exists():
205
+ claude_md.unlink()
206
+
207
+ # Generate fresh template
208
+ result = generate_from_manifest(
209
+ path, syntax="cli", files_to_generate=["CLAUDE.md"]
210
+ )
211
+ if isinstance(result, Failure):
212
+ return result
213
+
214
+ # Inject recovered user content
215
+ if user_content:
216
+ new_content = claude_md.read_text()
217
+ parsed = parse_invar_regions(new_content)
218
+ if "user" in parsed.regions:
219
+ updates = {"user": "\n" + user_content + "\n"}
220
+ final_content = reconstruct_file(parsed, updates)
221
+ claude_md.write_text(final_content)
222
+
223
+ return Success("recovered")
224
+
225
+
226
+ def _merge_with_preserved(path: Path, existing_content: str) -> Result[str, str]:
227
+ """Merge overwritten content as preserved user content."""
228
+ from pathlib import Path as PathLib # Runtime import for Path operations
229
+
230
+ # Format existing content as preserved
231
+ preserved = format_preserved_content(
232
+ existing_content, date.today().isoformat()
233
+ )
234
+
235
+ # Remove existing CLAUDE.md so generate_from_manifest creates fresh template
236
+ claude_md = PathLib(path) / "CLAUDE.md"
237
+ if claude_md.exists():
238
+ claude_md.unlink()
239
+
240
+ # Generate fresh template
241
+ result = generate_from_manifest(
242
+ path, syntax="cli", files_to_generate=["CLAUDE.md"]
243
+ )
244
+ if isinstance(result, Failure):
245
+ return result
246
+
247
+ # Inject preserved content into user region
248
+ new_content = claude_md.read_text()
249
+ parsed = parse_invar_regions(new_content)
250
+
251
+ if "user" in parsed.regions:
252
+ updates = {"user": "\n" + preserved + "\n"}
253
+ final_content = reconstruct_file(parsed, updates)
254
+ claude_md.write_text(final_content)
255
+
256
+ return Success("merged")
@@ -0,0 +1,113 @@
1
+ """
2
+ DX-56: Developer sync command for Invar (unified sync engine).
3
+
4
+ Shell module: Special command for updating Invar's own project files.
5
+ Uses MCP syntax and injects project-additions.md content.
6
+
7
+ CLI: `invar dev sync` (formerly `invar sync-self`)
8
+
9
+ This is a thin wrapper around the unified template_sync engine.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ import typer
17
+ from returns.result import Failure
18
+ from rich.console import Console
19
+
20
+ from invar.core.sync_helpers import SyncConfig
21
+ from invar.shell.commands.template_sync import sync_templates
22
+ from invar.shell.template_engine import is_invar_project
23
+
24
+ console = Console()
25
+
26
+
27
+ # @shell_complexity: CLI command with result display and multiple output branches
28
+ def sync_self(
29
+ path: Path = typer.Argument(Path(), help="Invar project root"),
30
+ check: bool = typer.Option(
31
+ False, "--check", help="Preview changes without applying"
32
+ ),
33
+ force: bool = typer.Option(
34
+ False, "--force", "-f", help="Update even if already current"
35
+ ),
36
+ ) -> None:
37
+ """
38
+ Synchronize Invar's own project files from templates.
39
+
40
+ DX-56: Now uses unified sync engine with manifest-driven file lists.
41
+
42
+ This command is for the Invar project only. It:
43
+ - Uses MCP syntax (invar_guard, invar_map, etc.)
44
+ - Injects .invar/project-additions.md into project region
45
+ - Updates managed regions while preserving user content
46
+ - Handles DX-55 state recovery (intact/partial/missing/absent)
47
+
48
+ Use --check to preview changes without applying them.
49
+ Use --force to update even if already current.
50
+ """
51
+ # Verify this is the Invar project
52
+ if not is_invar_project(path):
53
+ console.print("[red]Error:[/red] This command is only for the Invar project itself.")
54
+ console.print("[dim]Use 'invar init' for other projects.[/dim]")
55
+ raise typer.Exit(1)
56
+
57
+ console.print("[bold]Syncing Invar project files...[/bold]")
58
+ console.print("[dim]Using MCP syntax for templates[/dim]")
59
+ console.print()
60
+
61
+ # Configure for Invar project
62
+ config = SyncConfig(
63
+ syntax="mcp",
64
+ inject_project_additions=True,
65
+ force=force,
66
+ check=check,
67
+ )
68
+
69
+ # Run unified sync engine
70
+ result = sync_templates(path, config)
71
+
72
+ if isinstance(result, Failure):
73
+ console.print(f"[red]Error:[/red] {result.failure()}")
74
+ raise typer.Exit(1)
75
+
76
+ report = result.unwrap()
77
+
78
+ # Display results
79
+ if check:
80
+ console.print("[bold]Preview mode - no changes applied[/bold]")
81
+ console.print()
82
+
83
+ for file in report.created:
84
+ action = "Would create" if check else "Created"
85
+ console.print(f"[green]{action}[/green] {file}")
86
+
87
+ for file in report.updated:
88
+ action = "Would update" if check else "Updated"
89
+ console.print(f"[cyan]{action}[/cyan] {file}")
90
+
91
+ for file in report.skipped:
92
+ console.print(f"[dim]Skipped[/dim] {file} (unchanged)")
93
+
94
+ for error in report.errors:
95
+ console.print(f"[yellow]Warning:[/yellow] {error}")
96
+
97
+ # Summary
98
+ console.print()
99
+ if check:
100
+ console.print("[bold]Dry run complete.[/bold]")
101
+ console.print(f" Would create: {len(report.created)} files")
102
+ console.print(f" Would update: {len(report.updated)} files")
103
+ console.print(f" Would skip: {len(report.skipped)} files (unchanged)")
104
+ else:
105
+ console.print("[bold green]Sync complete![/bold green]")
106
+ console.print(f" Created: {len(report.created)} files")
107
+ console.print(f" Updated: {len(report.updated)} files")
108
+ console.print(f" Skipped: {len(report.skipped)} files (unchanged)")
109
+
110
+ console.print()
111
+ console.print("[dim]MCP syntax applied:[/dim]")
112
+ console.print("[dim] invar_guard(changed=true) instead of invar guard --changed[/dim]")
113
+ console.print("[dim] invar_map(top=10) instead of invar map --top 10[/dim]")