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.
- invar/__init__.py +1 -0
- invar/core/contracts.py +10 -10
- invar/core/entry_points.py +105 -32
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +1 -2
- invar/core/formatter.py +6 -7
- invar/core/hypothesis_strategies.py +5 -7
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -3
- invar/core/models.py +7 -1
- invar/core/must_use.py +2 -1
- invar/core/parser.py +7 -4
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +8 -5
- invar/core/purity.py +3 -3
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +78 -6
- invar/core/rule_meta.py +8 -0
- invar/core/rules.py +18 -19
- invar/core/shell_analysis.py +5 -10
- invar/core/shell_architecture.py +2 -2
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +86 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +102 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +13 -15
- invar/core/verification_routing.py +4 -7
- invar/mcp/server.py +100 -17
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +94 -14
- invar/shell/{init_cmd.py → commands/init.py} +179 -27
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +12 -24
- invar/shell/coverage.py +351 -0
- invar/shell/guard_helpers.py +38 -17
- invar/shell/guard_output.py +7 -1
- invar/shell/property_tests.py +58 -22
- invar/shell/prove/__init__.py +9 -0
- invar/shell/{prove.py → prove/crosshair.py} +40 -33
- invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +19 -0
- invar/shell/testing.py +71 -20
- invar/templates/CLAUDE.md.template +38 -17
- invar/templates/aider.conf.yml.template +2 -2
- invar/templates/commands/{review.md → audit.md} +20 -82
- invar/templates/commands/guard.md +77 -0
- invar/templates/config/CLAUDE.md.jinja +206 -0
- invar/templates/config/context.md.jinja +92 -0
- invar/templates/config/pre-commit.yaml.jinja +44 -0
- invar/templates/context.md.template +33 -0
- invar/templates/cursorrules.template +7 -4
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +5 -5
- invar/templates/examples/core_shell.py +11 -7
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
- invar/templates/skills/develop/SKILL.md.jinja +318 -0
- invar/templates/skills/investigate/SKILL.md.jinja +106 -0
- invar/templates/skills/propose/SKILL.md.jinja +104 -0
- invar/templates/skills/review/SKILL.md.jinja +125 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -58
- invar/resource.py +0 -99
- invar/shell/update_cmd.py +0 -193
- invar_tools-1.2.0.dist-info/RECORD +0 -77
- invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
- /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
- /invar/shell/{perception.py → commands/perception.py} +0 -0
- /invar/shell/{test_cmd.py → commands/test.py} +0 -0
- /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {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:
|
|
114
|
+
✓ Check-In: [project] | [branch] | [clean/dirty]
|
|
108
115
|
```
|
|
109
116
|
|
|
110
|
-
|
|
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
|
|
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 --
|
|
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
|
-
|
|
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
|
-
#
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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]")
|