invar-tools 1.0.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 (98) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +80 -10
  3. invar/core/entry_points.py +367 -0
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +195 -0
  6. invar/core/format_strategies.py +197 -0
  7. invar/core/formatter.py +32 -10
  8. invar/core/hypothesis_strategies.py +50 -10
  9. invar/core/inspect.py +1 -1
  10. invar/core/lambda_helpers.py +3 -2
  11. invar/core/models.py +30 -18
  12. invar/core/must_use.py +2 -1
  13. invar/core/parser.py +13 -6
  14. invar/core/postcondition_scope.py +128 -0
  15. invar/core/property_gen.py +86 -42
  16. invar/core/purity.py +13 -7
  17. invar/core/purity_heuristics.py +5 -9
  18. invar/core/references.py +8 -6
  19. invar/core/review_trigger.py +370 -0
  20. invar/core/rule_meta.py +69 -2
  21. invar/core/rules.py +91 -28
  22. invar/core/shell_analysis.py +247 -0
  23. invar/core/shell_architecture.py +171 -0
  24. invar/core/strategies.py +7 -14
  25. invar/core/suggestions.py +92 -0
  26. invar/core/sync_helpers.py +238 -0
  27. invar/core/tautology.py +103 -37
  28. invar/core/template_parser.py +467 -0
  29. invar/core/timeout_inference.py +4 -7
  30. invar/core/utils.py +63 -18
  31. invar/core/verification_routing.py +155 -0
  32. invar/mcp/server.py +113 -13
  33. invar/shell/commands/__init__.py +11 -0
  34. invar/shell/{cli.py → commands/guard.py} +152 -44
  35. invar/shell/{init_cmd.py → commands/init.py} +200 -28
  36. invar/shell/commands/merge.py +256 -0
  37. invar/shell/commands/mutate.py +184 -0
  38. invar/shell/{perception.py → commands/perception.py} +2 -0
  39. invar/shell/commands/sync_self.py +113 -0
  40. invar/shell/commands/template_sync.py +366 -0
  41. invar/shell/{test_cmd.py → commands/test.py} +3 -1
  42. invar/shell/commands/update.py +48 -0
  43. invar/shell/config.py +247 -10
  44. invar/shell/coverage.py +351 -0
  45. invar/shell/fs.py +5 -2
  46. invar/shell/git.py +2 -0
  47. invar/shell/guard_helpers.py +116 -20
  48. invar/shell/guard_output.py +106 -24
  49. invar/shell/mcp_config.py +3 -0
  50. invar/shell/mutation.py +314 -0
  51. invar/shell/property_tests.py +75 -24
  52. invar/shell/prove/__init__.py +9 -0
  53. invar/shell/prove/accept.py +113 -0
  54. invar/shell/{prove.py → prove/crosshair.py} +69 -30
  55. invar/shell/prove/hypothesis.py +293 -0
  56. invar/shell/subprocess_env.py +393 -0
  57. invar/shell/template_engine.py +345 -0
  58. invar/shell/templates.py +53 -0
  59. invar/shell/testing.py +77 -37
  60. invar/templates/CLAUDE.md.template +86 -9
  61. invar/templates/aider.conf.yml.template +16 -14
  62. invar/templates/commands/audit.md +138 -0
  63. invar/templates/commands/guard.md +77 -0
  64. invar/templates/config/CLAUDE.md.jinja +206 -0
  65. invar/templates/config/context.md.jinja +92 -0
  66. invar/templates/config/pre-commit.yaml.jinja +44 -0
  67. invar/templates/context.md.template +33 -0
  68. invar/templates/cursorrules.template +25 -13
  69. invar/templates/examples/README.md +2 -0
  70. invar/templates/examples/conftest.py +3 -0
  71. invar/templates/examples/contracts.py +4 -2
  72. invar/templates/examples/core_shell.py +10 -4
  73. invar/templates/examples/workflow.md +81 -0
  74. invar/templates/manifest.toml +137 -0
  75. invar/templates/protocol/INVAR.md +210 -0
  76. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  77. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  78. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  79. invar/templates/skills/review/SKILL.md.jinja +125 -0
  80. invar_tools-1.3.0.dist-info/METADATA +377 -0
  81. invar_tools-1.3.0.dist-info/RECORD +95 -0
  82. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  83. invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
  84. invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
  85. invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
  86. invar/contracts.py +0 -152
  87. invar/decorators.py +0 -94
  88. invar/invariant.py +0 -57
  89. invar/resource.py +0 -99
  90. invar/shell/prove_fallback.py +0 -183
  91. invar/shell/update_cmd.py +0 -191
  92. invar/templates/INVAR.md +0 -134
  93. invar_tools-1.0.0.dist-info/METADATA +0 -321
  94. invar_tools-1.0.0.dist-info/RECORD +0 -64
  95. invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
  96. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  97. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  98. {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,366 @@
1
+ """
2
+ DX-56: Unified template sync engine.
3
+
4
+ Shell module: Core sync logic shared by init and dev sync commands.
5
+ Handles state detection, manifest-driven file lists, region-based updates,
6
+ syntax switching, and project additions injection.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ from datetime import date
13
+ from pathlib import Path # noqa: TC003 - used at runtime
14
+
15
+ from returns.result import Failure, Result, Success
16
+
17
+ from invar.core.sync_helpers import (
18
+ SyncConfig,
19
+ SyncReport,
20
+ detect_region_scheme,
21
+ get_sync_file_lists,
22
+ should_skip_file,
23
+ )
24
+ from invar.core.template_parser import (
25
+ detect_claude_md_state,
26
+ format_preserved_content,
27
+ parse_invar_regions,
28
+ reconstruct_file,
29
+ strip_invar_markers,
30
+ )
31
+ from invar.shell.template_engine import (
32
+ get_templates_dir,
33
+ load_manifest,
34
+ render_template_file,
35
+ )
36
+
37
+ # Re-export for convenience
38
+ __all__ = ["SyncConfig", "SyncReport", "sync_templates"]
39
+
40
+
41
+ # =============================================================================
42
+ # Core Sync Logic
43
+ # =============================================================================
44
+
45
+
46
+ # @shell_complexity: Multi-path sync with state detection and region handling
47
+ def sync_templates(path: Path, config: SyncConfig) -> Result[SyncReport, str]:
48
+ """Unified template sync engine.
49
+
50
+ DX-56: Core sync logic shared by init and dev sync commands.
51
+
52
+ Handles:
53
+ 1. State detection (DX-55: intact/partial/missing/absent)
54
+ 2. Manifest-driven file list
55
+ 3. Region-based updates (managed/user/project/skill/extensions)
56
+ 4. Syntax switching (CLI vs MCP)
57
+ 5. Project additions injection
58
+
59
+ Args:
60
+ path: Project root directory
61
+ config: Sync configuration
62
+
63
+ Returns:
64
+ Success with SyncReport, or Failure with error message
65
+ """
66
+ templates_dir = get_templates_dir()
67
+ manifest_result = load_manifest(templates_dir)
68
+ if isinstance(manifest_result, Failure):
69
+ return manifest_result
70
+
71
+ manifest = manifest_result.unwrap()
72
+ report = SyncReport()
73
+
74
+ # Build variables for template rendering
75
+ variables = {**manifest.get("variables", {}), "syntax": config.syntax}
76
+
77
+ # Load project additions if enabled
78
+ project_additions = _load_project_additions(path) if config.inject_project_additions else ""
79
+
80
+ # Get file lists from manifest
81
+ fully_managed, region_managed, create_only = get_sync_file_lists(manifest)
82
+
83
+ # Process fully managed files (direct overwrite)
84
+ for dest_rel, src_rel in fully_managed:
85
+ if should_skip_file(dest_rel, config.skip_patterns):
86
+ continue
87
+ result = _sync_fully_managed(path, templates_dir, dest_rel, src_rel, config, report)
88
+ if isinstance(result, Failure):
89
+ report.errors.append(result.failure())
90
+
91
+ # Process region-managed files
92
+ for dest_rel, src_rel in region_managed:
93
+ if should_skip_file(dest_rel, config.skip_patterns):
94
+ continue
95
+ result = _sync_region_managed(
96
+ path, templates_dir, dest_rel, src_rel,
97
+ config, variables, project_additions, report
98
+ )
99
+ if isinstance(result, Failure):
100
+ report.errors.append(result.failure())
101
+
102
+ # Process create-only files (only if not exists)
103
+ for dest_rel in create_only:
104
+ if should_skip_file(dest_rel, config.skip_patterns):
105
+ continue
106
+ if dest_rel in manifest.get("templates", {}):
107
+ template_config = manifest["templates"][dest_rel]
108
+ result = _sync_create_only(
109
+ path, templates_dir, dest_rel, template_config, variables, report
110
+ )
111
+ if isinstance(result, Failure):
112
+ report.errors.append(result.failure())
113
+
114
+ return Success(report)
115
+
116
+
117
+ def _load_project_additions(path: Path) -> str:
118
+ """Load project-additions.md content if it exists."""
119
+ pa_path = path / ".invar" / "project-additions.md"
120
+ if pa_path.exists():
121
+ try:
122
+ return pa_path.read_text()
123
+ except OSError:
124
+ pass
125
+ return ""
126
+
127
+
128
+ # @shell_complexity: File I/O with multiple existence/content checks
129
+ def _sync_fully_managed(
130
+ path: Path,
131
+ templates_dir: Path,
132
+ dest_rel: str,
133
+ src_rel: str,
134
+ config: SyncConfig,
135
+ report: SyncReport,
136
+ ) -> Result[str, str]:
137
+ """Sync a fully managed file (direct overwrite)."""
138
+ dest_file = path / dest_rel
139
+ src_file = templates_dir / src_rel
140
+
141
+ if not src_file.exists():
142
+ return Failure(f"Template not found: {src_rel}")
143
+
144
+ try:
145
+ new_content = src_file.read_text()
146
+ except OSError as e:
147
+ return Failure(f"Failed to read template {src_rel}: {e}")
148
+
149
+ # Check if update needed
150
+ if dest_file.exists() and not config.force:
151
+ try:
152
+ if dest_file.read_text() == new_content:
153
+ report.skipped.append(dest_rel)
154
+ return Success("skipped")
155
+ except OSError:
156
+ pass
157
+
158
+ # Write file (unless check mode)
159
+ if not config.check:
160
+ try:
161
+ dest_file.parent.mkdir(parents=True, exist_ok=True)
162
+ dest_file.write_text(new_content)
163
+ except OSError as e:
164
+ return Failure(f"Failed to write {dest_rel}: {e}")
165
+
166
+ report.updated.append(dest_rel) if dest_file.exists() else report.created.append(dest_rel)
167
+ return Success("synced")
168
+
169
+
170
+ # @shell_complexity: Region-based sync with DX-55 state detection and content merging
171
+ def _sync_region_managed(
172
+ path: Path,
173
+ templates_dir: Path,
174
+ dest_rel: str,
175
+ src_rel: str,
176
+ config: SyncConfig,
177
+ variables: dict,
178
+ project_additions: str,
179
+ report: SyncReport,
180
+ ) -> Result[str, str]:
181
+ """Sync a region-managed file (update managed regions, preserve user)."""
182
+ dest_file = path / dest_rel
183
+ src_file = templates_dir / src_rel
184
+
185
+ if not src_file.exists():
186
+ return Failure(f"Template not found: {src_rel}")
187
+
188
+ # Render template
189
+ render_result = render_template_file(src_file, variables)
190
+ if isinstance(render_result, Failure):
191
+ return render_result
192
+
193
+ new_content = render_result.unwrap()
194
+ new_parsed = parse_invar_regions(new_content)
195
+
196
+ # Detect region scheme
197
+ region_scheme = detect_region_scheme(new_parsed)
198
+ if region_scheme is None:
199
+ return Failure(f"No region markers in template: {src_rel}")
200
+
201
+ primary_region, user_region = region_scheme
202
+
203
+ # Handle new file
204
+ if not dest_file.exists():
205
+ return _create_new_region_file(
206
+ dest_file, dest_rel, new_content, new_parsed, project_additions, config, report
207
+ )
208
+
209
+ # Read existing file
210
+ try:
211
+ existing_content = dest_file.read_text()
212
+ except UnicodeDecodeError:
213
+ # Binary content - replace entirely
214
+ if not config.check:
215
+ dest_file.unlink()
216
+ dest_file.write_text(new_content)
217
+ report.updated.append(dest_rel)
218
+ return Success("replaced_binary")
219
+ except OSError as e:
220
+ return Failure(f"Failed to read {dest_rel}: {e}")
221
+
222
+ # Process based on DX-55 state
223
+ final_content = _merge_region_content(
224
+ existing_content, new_content, new_parsed,
225
+ primary_region, user_region, dest_rel, project_additions, config
226
+ )
227
+
228
+ # Check if changed
229
+ if final_content == existing_content and not config.force:
230
+ report.skipped.append(dest_rel)
231
+ return Success("skipped")
232
+
233
+ # Write updated file
234
+ if not config.check:
235
+ try:
236
+ dest_file.write_text(final_content)
237
+ except OSError as e:
238
+ return Failure(f"Failed to write {dest_rel}: {e}")
239
+
240
+ report.updated.append(dest_rel)
241
+ return Success("updated")
242
+
243
+
244
+ def _create_new_region_file(
245
+ dest_file: Path,
246
+ dest_rel: str,
247
+ new_content: str,
248
+ new_parsed,
249
+ project_additions: str,
250
+ config: SyncConfig,
251
+ report: SyncReport,
252
+ ) -> Result[str, str]:
253
+ """Create a new region-managed file."""
254
+ final_content = new_content
255
+
256
+ # Inject project additions for CLAUDE.md
257
+ if dest_rel == "CLAUDE.md" and project_additions and "project" in new_parsed.regions:
258
+ updates = {"project": project_additions}
259
+ final_content = reconstruct_file(new_parsed, updates)
260
+
261
+ if not config.check:
262
+ try:
263
+ dest_file.parent.mkdir(parents=True, exist_ok=True)
264
+ dest_file.write_text(final_content)
265
+ except OSError as e:
266
+ return Failure(f"Failed to write {dest_rel}: {e}")
267
+
268
+ report.created.append(dest_rel)
269
+ return Success("created")
270
+
271
+
272
+ # @shell_orchestration: DX-55 state-based merge logic with multiple recovery paths
273
+ # @shell_complexity: Multiple state branches (intact/partial/missing)
274
+ def _merge_region_content(
275
+ existing_content: str,
276
+ new_content: str,
277
+ new_parsed,
278
+ primary_region: str,
279
+ user_region: str,
280
+ dest_rel: str,
281
+ project_additions: str,
282
+ config: SyncConfig,
283
+ ) -> str:
284
+ """Merge existing content with new template based on DX-55 state."""
285
+ state = detect_claude_md_state(existing_content)
286
+ updates: dict[str, str] = {}
287
+
288
+ if state.state == "intact":
289
+ # Just update managed region, preserve user
290
+ existing_parsed = parse_invar_regions(existing_content)
291
+ updates[primary_region] = new_parsed.regions[primary_region].content
292
+ if dest_rel == "CLAUDE.md" and project_additions and "project" in existing_parsed.regions:
293
+ updates["project"] = project_additions
294
+ return reconstruct_file(existing_parsed, updates)
295
+
296
+ elif state.state == "partial":
297
+ # Corruption: salvage user content
298
+ user_content = state.user_content or strip_invar_markers(existing_content)
299
+ if user_content:
300
+ user_content = format_preserved_content(user_content, date.today().isoformat())
301
+ parsed = parse_invar_regions(new_content)
302
+ if user_region in parsed.regions and user_content:
303
+ updates = {user_region: "\n" + user_content + "\n"}
304
+ if dest_rel == "CLAUDE.md" and project_additions and "project" in parsed.regions:
305
+ updates["project"] = project_additions
306
+ return reconstruct_file(parsed, updates)
307
+ return new_content
308
+
309
+ elif state.state == "missing":
310
+ # No Invar markers - preserve entire content as user content
311
+ preserved = format_preserved_content(existing_content, date.today().isoformat())
312
+ parsed = parse_invar_regions(new_content)
313
+ if user_region in parsed.regions:
314
+ updates = {user_region: "\n" + preserved + "\n"}
315
+ if dest_rel == "CLAUDE.md" and project_additions and "project" in parsed.regions:
316
+ updates["project"] = project_additions
317
+ return reconstruct_file(parsed, updates)
318
+ return new_content
319
+
320
+ return new_content
321
+
322
+
323
+ # @shell_complexity: File creation with multiple template types
324
+ def _sync_create_only(
325
+ path: Path,
326
+ templates_dir: Path,
327
+ dest_rel: str,
328
+ template_config: dict,
329
+ variables: dict,
330
+ report: SyncReport,
331
+ ) -> Result[str, str]:
332
+ """Sync a create-only file (only create if not exists)."""
333
+ dest_file = path / dest_rel
334
+ src_rel = template_config.get("src", "")
335
+ template_type = template_config.get("type", "copy")
336
+ src_file = templates_dir / src_rel
337
+
338
+ # Skip if already exists
339
+ if dest_file.exists():
340
+ report.skipped.append(dest_rel)
341
+ return Success("skipped")
342
+
343
+ if not src_file.exists():
344
+ return Failure(f"Template not found: {src_rel}")
345
+
346
+ try:
347
+ dest_file.parent.mkdir(parents=True, exist_ok=True)
348
+
349
+ if template_type == "copy":
350
+ dest_file.write_text(src_file.read_text())
351
+ elif template_type == "jinja":
352
+ result = render_template_file(src_file, variables)
353
+ if isinstance(result, Failure):
354
+ return result
355
+ dest_file.write_text(result.unwrap())
356
+ elif template_type == "copy_dir":
357
+ if src_file.is_dir():
358
+ shutil.copytree(src_file, dest_file)
359
+ else:
360
+ return Failure(f"Expected directory: {src_rel}")
361
+
362
+ report.created.append(dest_rel)
363
+ return Success("created")
364
+
365
+ except OSError as e:
366
+ return Failure(f"Failed to create {dest_rel}: {e}")
@@ -26,6 +26,7 @@ def _detect_agent_mode() -> bool:
26
26
  return os.getenv("INVAR_MODE") == "agent" or not sys.stdout.isatty()
27
27
 
28
28
 
29
+ # @shell_complexity: Test command with file collection and output
29
30
  def test(
30
31
  target: str = typer.Argument(None, help="File to test (optional with --changed)"),
31
32
  verbose: bool = typer.Option(False, "-v", "--verbose", help="Verbose output"),
@@ -33,7 +34,7 @@ def test(
33
34
  changed: bool = typer.Option(False, "--changed", help="Test git-modified files only"),
34
35
  max_examples: int = typer.Option(100, "--max-examples", help="Maximum Hypothesis examples per function"),
35
36
  ) -> None:
36
- """Run property-based tests using Hypothesis on contracted functions (DX-08)."""
37
+ """Run property-based tests using Hypothesis on contracted functions."""
37
38
  from invar.shell.property_tests import (
38
39
  format_property_test_report,
39
40
  run_property_tests_on_files,
@@ -75,6 +76,7 @@ def test(
75
76
  raise typer.Exit(1)
76
77
 
77
78
 
79
+ # @shell_complexity: Verify command with CrossHair integration
78
80
  def verify(
79
81
  target: str = typer.Argument(None, help="File to verify (optional with --changed)"),
80
82
  timeout: int = typer.Option(30, "--timeout", help="Timeout per function (seconds)"),
@@ -0,0 +1,48 @@
1
+ """
2
+ Update command for Invar.
3
+
4
+ DX-55: Now an alias for 'invar init' (unified idempotent command).
5
+ Maintained for backwards compatibility.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from rich.console import Console
14
+
15
+ from invar.shell.commands.init import init as init_command
16
+
17
+ console = Console()
18
+
19
+
20
+ def update(
21
+ path: Path = typer.Argument(Path(), help="Project root directory"),
22
+ check: bool = typer.Option(False, "--check", help="Preview changes"),
23
+ force: bool = typer.Option(False, "--force", "-f", help="Update even if current"),
24
+ yes: bool = typer.Option(False, "--yes", "-y", help="Accept defaults without prompting"),
25
+ ) -> None:
26
+ """
27
+ Alias for 'invar init' (DX-55).
28
+
29
+ Maintained for backwards compatibility.
30
+ Both commands are now idempotent and do the same thing.
31
+
32
+ Use 'invar init --check' to preview changes.
33
+ Use 'invar init --force' to refresh even if current.
34
+ """
35
+ console.print("[dim]Note: 'update' is now an alias for 'init'[/dim]")
36
+ # Pass all init parameters with explicit defaults to avoid typer.Option object issues
37
+ return init_command(
38
+ path=path,
39
+ claude=False,
40
+ mcp_method=None,
41
+ dirs=None,
42
+ hooks=True,
43
+ skills=True,
44
+ yes=yes,
45
+ check=check,
46
+ force=force,
47
+ reset=False,
48
+ )