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
@@ -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}")
@@ -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
+ )
invar/shell/config.py CHANGED
@@ -39,27 +39,11 @@ class ModuleType(Enum):
39
39
 
40
40
 
41
41
  # I/O libraries that indicate Shell module (for AST import checking)
42
- _IO_LIBRARIES = frozenset(
43
- [
44
- "os",
45
- "sys",
46
- "subprocess",
47
- "pathlib",
48
- "shutil",
49
- "io",
50
- "socket",
51
- "requests",
52
- "aiohttp",
53
- "httpx",
54
- "urllib",
55
- "sqlite3",
56
- "psycopg2",
57
- "pymongo",
58
- "sqlalchemy",
59
- "typer",
60
- "click",
61
- ]
62
- )
42
+ _IO_LIBRARIES = frozenset([
43
+ "os", "sys", "subprocess", "pathlib", "shutil", "io", "socket",
44
+ "requests", "aiohttp", "httpx", "urllib", "sqlite3", "psycopg2",
45
+ "pymongo", "sqlalchemy", "typer", "click",
46
+ ])
63
47
 
64
48
  # Contract decorator names
65
49
  _CONTRACT_DECORATORS = frozenset(["pre", "post", "invariant"])
@@ -68,7 +52,8 @@ _CONTRACT_DECORATORS = frozenset(["pre", "post", "invariant"])
68
52
  _RESULT_TYPES = frozenset(["Result", "Success", "Failure"])
69
53
 
70
54
 
71
- # @shell_orchestration: AST analysis helpers for module classification
55
+ # @shell_orchestration: AST analysis
56
+ # @shell_complexity: AST branches
72
57
  def _has_contract_decorators(tree: ast.Module) -> bool:
73
58
  """
74
59
  Check if AST contains @pre/@post contract decorators.
@@ -100,7 +85,8 @@ def _has_contract_decorators(tree: ast.Module) -> bool:
100
85
  return False
101
86
 
102
87
 
103
- # @shell_orchestration: AST analysis helper for module classification
88
+ # @shell_orchestration: AST analysis
89
+ # @shell_complexity: AST branches
104
90
  def _has_io_imports(tree: ast.Module) -> bool:
105
91
  """
106
92
  Check if AST contains imports of I/O libraries.
@@ -133,7 +119,8 @@ def _has_io_imports(tree: ast.Module) -> bool:
133
119
  return False
134
120
 
135
121
 
136
- # @shell_orchestration: AST analysis helper for module classification
122
+ # @shell_orchestration: AST analysis
123
+ # @shell_complexity: AST branches
137
124
  def _has_result_types(tree: ast.Module) -> bool:
138
125
  """
139
126
  Check if AST contains Result/Success/Failure usage.
@@ -425,6 +412,7 @@ def get_exclude_paths(project_root: Path) -> Result[list[str], str]:
425
412
  return Success(guard_config.get("exclude_paths", _DEFAULT_EXCLUDE_PATHS.copy()))
426
413
 
427
414
 
415
+ # @shell_complexity: Classification decision tree requires multiple config lookups and priority checks
428
416
  # @invar:allow entry_point_too_thick: False positive - .get() matches router.get pattern
429
417
  def classify_file(
430
418
  file_path: str, project_root: Path, source: str = ""