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
invar/mcp/server.py CHANGED
@@ -3,18 +3,54 @@ Invar MCP Server implementation.
3
3
 
4
4
  Exposes invar guard, sig, and map as first-class MCP tools.
5
5
  Part of DX-16: Agent Tool Enforcement.
6
+ DX-52: Added Phase 2 smart re-spawn for project Python compatibility.
6
7
  """
7
8
 
8
9
  from __future__ import annotations
9
10
 
10
11
  import json
12
+ import os
11
13
  import subprocess
12
14
  import sys
15
+ from pathlib import Path
13
16
  from typing import Any
14
17
 
15
18
  from mcp.server import Server
16
19
  from mcp.types import TextContent, Tool
17
20
 
21
+ from invar.shell.subprocess_env import should_respawn
22
+
23
+
24
+ # @invar:allow shell_result: Pure validation helper, no I/O, returns tuple not Result
25
+ # @shell_complexity: Security validation requires multiple checks
26
+ def _validate_path(path: str) -> tuple[bool, str]:
27
+ """Validate path argument for safety.
28
+
29
+ Returns (is_valid, error_message).
30
+ Rejects paths that could be interpreted as shell commands or flags.
31
+ """
32
+ if not path:
33
+ return True, "" # Empty path defaults to "." in handlers
34
+
35
+ # Reject if looks like a flag (starts with -)
36
+ if path.startswith("-"):
37
+ return False, f"Invalid path: cannot start with '-': {path}"
38
+
39
+ # Reject shell metacharacters that could cause issues
40
+ dangerous_chars = [";", "&", "|", "$", "`", "\n", "\r"]
41
+ for char in dangerous_chars:
42
+ if char in path:
43
+ return False, f"Invalid path: contains forbidden character: {char!r}"
44
+
45
+ # Try to resolve path - this catches malformed paths
46
+ try:
47
+ Path(path).resolve()
48
+ except (OSError, ValueError) as e:
49
+ return False, f"Invalid path: {e}"
50
+
51
+ return True, ""
52
+
53
+
18
54
  # Strong instructions for agent behavior (DX-16 + DX-17 + DX-26)
19
55
  INVAR_INSTRUCTIONS = """
20
56
  ## Invar Tool Usage (MANDATORY)
@@ -84,7 +120,7 @@ the MCP tools and may not follow the correct workflow.
84
120
 
85
121
 
86
122
  # @shell_orchestration: MCP tool factory - creates Tool objects
87
- # @invar:allow shell_result: MCP framework API returns Tool
123
+ # @invar:allow shell_result: MCP tool factory for guard command
88
124
  def _get_guard_tool() -> Tool:
89
125
  """Define the invar_guard tool."""
90
126
  return Tool(
@@ -100,13 +136,14 @@ def _get_guard_tool() -> Tool:
100
136
  "path": {"type": "string", "description": "Project path (default: .)", "default": "."},
101
137
  "changed": {"type": "boolean", "description": "Only verify git-changed files", "default": True},
102
138
  "strict": {"type": "boolean", "description": "Treat warnings as errors", "default": False},
139
+ "coverage": {"type": "boolean", "description": "DX-37: Collect branch coverage from doctest + hypothesis", "default": False},
103
140
  },
104
141
  },
105
142
  )
106
143
 
107
144
 
108
145
  # @shell_orchestration: MCP tool factory - creates Tool objects
109
- # @invar:allow shell_result: MCP framework API returns Tool
146
+ # @invar:allow shell_result: MCP tool factory for sig command
110
147
  def _get_sig_tool() -> Tool:
111
148
  """Define the invar_sig tool."""
112
149
  return Tool(
@@ -126,7 +163,7 @@ def _get_sig_tool() -> Tool:
126
163
 
127
164
 
128
165
  # @shell_orchestration: MCP tool factory - creates Tool objects
129
- # @invar:allow shell_result: MCP framework API returns Tool
166
+ # @invar:allow shell_result: MCP tool factory for map command
130
167
  def _get_map_tool() -> Tool:
131
168
  """Define the invar_map tool."""
132
169
  return Tool(
@@ -167,18 +204,25 @@ def create_server() -> Server:
167
204
 
168
205
 
169
206
  # @shell_orchestration: MCP handler - subprocess is called inside
170
- # @invar:allow shell_result: MCP framework API returns list[TextContent]
207
+ # @shell_complexity: Guard command with multiple optional flags
208
+ # @invar:allow shell_result: MCP handler for guard tool
171
209
  async def _run_guard(args: dict[str, Any]) -> list[TextContent]:
172
210
  """Run invar guard command."""
173
- cmd = [sys.executable, "-m", "invar.shell.cli", "guard"]
174
-
175
211
  path = args.get("path", ".")
212
+ is_valid, error = _validate_path(path)
213
+ if not is_valid:
214
+ return [TextContent(type="text", text=f"Error: {error}")]
215
+
216
+ cmd = [sys.executable, "-m", "invar.shell.commands.guard", "guard"]
176
217
  cmd.append(path)
177
218
 
178
219
  if args.get("changed", True):
179
220
  cmd.append("--changed")
180
221
  if args.get("strict", False):
181
222
  cmd.append("--strict")
223
+ # DX-37: Optional coverage collection
224
+ if args.get("coverage", False):
225
+ cmd.append("--coverage")
182
226
 
183
227
  # DX-26: TTY auto-detection - MCP runs in non-TTY, so agent JSON output is automatic
184
228
  # No explicit flag needed
@@ -187,24 +231,33 @@ async def _run_guard(args: dict[str, Any]) -> list[TextContent]:
187
231
 
188
232
 
189
233
  # @shell_orchestration: MCP handler - subprocess is called inside
190
- # @invar:allow shell_result: MCP framework API returns list[TextContent]
234
+ # @invar:allow shell_result: MCP handler for sig tool
191
235
  async def _run_sig(args: dict[str, Any]) -> list[TextContent]:
192
236
  """Run invar sig command."""
193
237
  target = args.get("target", "")
194
238
  if not target:
195
239
  return [TextContent(type="text", text="Error: target is required")]
196
240
 
197
- cmd = [sys.executable, "-m", "invar.shell.cli", "sig", target, "--json"]
241
+ # Validate target (can be file path or file::symbol)
242
+ target_path = target.split("::")[0] if "::" in target else target
243
+ is_valid, error = _validate_path(target_path)
244
+ if not is_valid:
245
+ return [TextContent(type="text", text=f"Error: {error}")]
246
+
247
+ cmd = [sys.executable, "-m", "invar.shell.commands.guard", "sig", target, "--json"]
198
248
  return await _execute_command(cmd)
199
249
 
200
250
 
201
251
  # @shell_orchestration: MCP handler - subprocess is called inside
202
- # @invar:allow shell_result: MCP framework API returns list[TextContent]
252
+ # @invar:allow shell_result: MCP handler for map tool
203
253
  async def _run_map(args: dict[str, Any]) -> list[TextContent]:
204
254
  """Run invar map command."""
205
- cmd = [sys.executable, "-m", "invar.shell.cli", "map"]
206
-
207
255
  path = args.get("path", ".")
256
+ is_valid, error = _validate_path(path)
257
+ if not is_valid:
258
+ return [TextContent(type="text", text=f"Error: {error}")]
259
+
260
+ cmd = [sys.executable, "-m", "invar.shell.commands.guard", "map"]
208
261
  cmd.append(path)
209
262
 
210
263
  top = args.get("top", 10)
@@ -215,15 +268,20 @@ async def _run_map(args: dict[str, Any]) -> list[TextContent]:
215
268
 
216
269
 
217
270
  # @shell_complexity: Command execution with error handling branches
218
- # @invar:allow shell_result: MCP framework API returns list[TextContent]
219
- async def _execute_command(cmd: list[str]) -> list[TextContent]:
220
- """Execute a command and return the result."""
271
+ # @invar:allow shell_result: MCP subprocess wrapper utility
272
+ async def _execute_command(cmd: list[str], timeout: int = 600) -> list[TextContent]:
273
+ """Execute a command and return the result.
274
+
275
+ Args:
276
+ cmd: Command to execute
277
+ timeout: Maximum time in seconds (default: 600, accommodates full Guard cycle)
278
+ """
221
279
  try:
222
280
  result = subprocess.run(
223
281
  cmd,
224
282
  capture_output=True,
225
283
  text=True,
226
- timeout=120,
284
+ timeout=timeout,
227
285
  )
228
286
 
229
287
  output = result.stdout
@@ -240,18 +298,43 @@ async def _execute_command(cmd: list[str]) -> list[TextContent]:
240
298
  return [TextContent(type="text", text=output)]
241
299
 
242
300
  except subprocess.TimeoutExpired:
243
- return [TextContent(type="text", text="Error: Command timed out (120s)")]
301
+ return [TextContent(type="text", text=f"Error: Command timed out ({timeout}s)")]
244
302
  except Exception as e:
245
303
  return [TextContent(type="text", text=f"Error: {e}")]
246
304
 
247
305
 
248
306
  # @shell_orchestration: MCP server entry point - runs async server
249
307
  def run_server() -> None:
250
- """Run the Invar MCP server."""
308
+ """Run the Invar MCP server.
309
+
310
+ DX-52 Phase 2: If project has invar installed, re-spawn with project Python
311
+ to ensure C extensions are compatible with project's Python version.
312
+ """
251
313
  import asyncio
252
314
 
253
315
  from mcp.server.stdio import stdio_server
254
316
 
317
+ # DX-52 Phase 2: Smart re-spawn with project Python
318
+ cwd = Path.cwd()
319
+ do_respawn, project_python = should_respawn(cwd)
320
+
321
+ if do_respawn and project_python is not None:
322
+ # Re-spawn with project Python (has both invar AND project deps)
323
+ import subprocess
324
+ import sys
325
+
326
+ if os.name == "nt":
327
+ # Windows: execv doesn't replace process, use subprocess + exit
328
+ result = subprocess.call([str(project_python), "-m", "invar.mcp"])
329
+ sys.exit(result)
330
+ else:
331
+ # Unix: execv replaces current process, does not return
332
+ os.execv(
333
+ str(project_python),
334
+ [str(project_python), "-m", "invar.mcp"],
335
+ )
336
+
337
+ # Phase 1 fallback: Continue with uvx + PYTHONPATH injection
255
338
  async def main():
256
339
  server = create_server()
257
340
  async with stdio_server() as (read_stream, write_stream):
@@ -0,0 +1,11 @@
1
+ """
2
+ CLI commands for Invar.
3
+
4
+ This module contains Typer command implementations:
5
+ - guard: Main verification command
6
+ - init: Project initialization
7
+ - update: Update INVAR.md from template
8
+ - test: Run property tests
9
+ - mutate: Run mutation testing
10
+ - perception: Map and sig commands
11
+ """
@@ -56,14 +56,19 @@ def _count_core_functions(file_info) -> tuple[int, int]:
56
56
  return (total, with_contracts)
57
57
 
58
58
 
59
+ # @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
59
60
  # @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
60
61
  def _scan_and_check(
61
62
  path: Path, config: RuleConfig, only_files: set[Path] | None = None
62
63
  ) -> Result[GuardReport, str]:
63
64
  """Scan project files and check against rules."""
65
+ from invar.core.entry_points import extract_escape_hatches
66
+ from invar.core.review_trigger import check_duplicate_escape_reasons
64
67
  from invar.core.shell_architecture import check_complexity_debt
65
68
 
66
69
  report = GuardReport(files_checked=0)
70
+ all_escapes: list[tuple[str, str, str]] = [] # DX-33: (file, rule, reason)
71
+
67
72
  for file_result in scan_project(path, only_files):
68
73
  if isinstance(file_result, Failure):
69
74
  console.print(f"[yellow]Warning:[/yellow] {file_result.failure()}")
@@ -75,6 +80,10 @@ def _scan_and_check(
75
80
  report.update_coverage(total, with_contracts)
76
81
  for violation in check_all_rules(file_info, config):
77
82
  report.add_violation(violation)
83
+ # DX-33: Collect escape hatches for cross-file analysis
84
+ if file_info.source:
85
+ for rule, reason in extract_escape_hatches(file_info.source):
86
+ all_escapes.append((file_info.path, rule, reason))
78
87
 
79
88
  # DX-22: Check project-level complexity debt (Fix-or-Explain enforcement)
80
89
  for debt_violation in check_complexity_debt(
@@ -82,6 +91,10 @@ def _scan_and_check(
82
91
  ):
83
92
  report.add_violation(debt_violation)
84
93
 
94
+ # DX-33: Check for duplicate escape reasons across files
95
+ for escape_violation in check_duplicate_escape_reasons(all_escapes):
96
+ report.add_violation(escape_violation)
97
+
85
98
  return Success(report)
86
99
 
87
100
 
@@ -117,6 +130,9 @@ def guard(
117
130
  json_output: bool = typer.Option(
118
131
  False, "--json", hidden=True, help="[Deprecated] Use TTY auto-detection instead"
119
132
  ),
133
+ coverage: bool = typer.Option(
134
+ False, "--coverage", help="DX-37: Collect branch coverage from doctest + hypothesis"
135
+ ),
120
136
  ) -> None:
121
137
  """Check project against Invar architecture rules.
122
138
 
@@ -179,32 +195,76 @@ def guard(
179
195
  # Run verification phases
180
196
  static_exit_code = get_exit_code(report, strict)
181
197
  doctest_passed, doctest_output = True, ""
182
- crosshair_passed, crosshair_output = True, {}
183
- property_passed, property_output = True, {}
198
+ crosshair_passed: bool = True
199
+ crosshair_output: dict = {}
200
+ property_passed: bool = True
201
+ property_output: dict = {}
202
+ # DX-37: Coverage data from doctest + hypothesis phases
203
+ doctest_coverage: dict | None = None
204
+ property_coverage: dict | None = None
205
+
206
+ # DX-37: Check coverage availability if requested
207
+ if coverage:
208
+ from invar.shell.coverage import check_coverage_available
209
+ cov_check = check_coverage_available()
210
+ if isinstance(cov_check, Failure):
211
+ console.print(f"[yellow]Warning:[/yellow] {cov_check.failure()}")
212
+ coverage = False # Disable coverage if not available
184
213
 
185
214
  # DX-19: STANDARD runs all verification phases
186
215
  if verification_level == VerificationLevel.STANDARD and static_exit_code == 0:
187
216
  checked_files = collect_files_to_check(path, checked_files)
188
217
 
189
- # Phase 1: Doctests
190
- doctest_passed, doctest_output = run_doctests_phase(checked_files, explain)
218
+ # Phase 1: Doctests (DX-37: with optional coverage)
219
+ doctest_passed, doctest_output, doctest_coverage = run_doctests_phase(
220
+ checked_files, explain, timeout=config.timeout_doctest,
221
+ collect_coverage=coverage,
222
+ )
191
223
 
192
224
  # Phase 2: CrossHair symbolic verification
225
+ # Note: CrossHair uses subprocess + symbolic execution, coverage not applicable
193
226
  crosshair_passed, crosshair_output = run_crosshair_phase(
194
227
  path, checked_files, doctest_passed, static_exit_code,
195
228
  changed_mode=changed,
229
+ timeout=config.timeout_crosshair,
230
+ per_condition_timeout=config.timeout_crosshair_per_condition,
196
231
  )
197
232
 
198
- # Phase 3: Hypothesis property tests
199
- property_passed, property_output = run_property_tests_phase(
200
- checked_files, doctest_passed, static_exit_code
233
+ # Phase 3: Hypothesis property tests (DX-37: with optional coverage)
234
+ property_passed, property_output, property_coverage = run_property_tests_phase(
235
+ checked_files, doctest_passed, static_exit_code,
236
+ collect_coverage=coverage,
201
237
  )
238
+ elif verification_level == VerificationLevel.STATIC:
239
+ # Static-only mode: explicitly mark verification as skipped
240
+ crosshair_output = {"status": "skipped", "reason": "static mode"}
241
+ property_output = {"status": "skipped", "reason": "static mode"}
242
+ elif static_exit_code != 0:
243
+ # Static failures: explicitly mark verification as skipped
244
+ crosshair_output = {"status": "skipped", "reason": "prior failures"}
245
+ property_output = {"status": "skipped", "reason": "prior failures"}
246
+
247
+ # DX-37: Merge coverage data from doctest + hypothesis
248
+ coverage_output: dict | None = None
249
+ if coverage and (doctest_coverage or property_coverage):
250
+ coverage_output = {
251
+ "enabled": True,
252
+ "phases_tracked": [],
253
+ "phases_excluded": ["crosshair"], # CrossHair uses symbolic execution
254
+ }
255
+ if doctest_coverage and doctest_coverage.get("collected"):
256
+ coverage_output["phases_tracked"].append("doctest")
257
+ if property_coverage and property_coverage.get("collected"):
258
+ coverage_output["phases_tracked"].append("hypothesis")
259
+ if "overall_branch_coverage" in property_coverage:
260
+ coverage_output["overall_branch_coverage"] = property_coverage["overall_branch_coverage"]
202
261
 
203
262
  # DX-26: Unified output (agent JSON or human Rich)
204
263
  if use_agent_output:
205
264
  output_agent(
206
265
  report, strict, doctest_passed, doctest_output, crosshair_output, level_name,
207
266
  property_output=property_output,
267
+ coverage_data=coverage_output, # DX-37
208
268
  )
209
269
  else:
210
270
  output_rich(report, config.strict_pure, changed, pedantic, explain, static)
@@ -214,6 +274,13 @@ def guard(
214
274
  property_output=property_output,
215
275
  strict=strict,
216
276
  )
277
+ # DX-37: Show coverage info in human output
278
+ if coverage_output and coverage_output.get("phases_tracked"):
279
+ phases = coverage_output.get("phases_tracked", [])
280
+ overall = coverage_output.get("overall_branch_coverage", 0.0)
281
+ console.print(f"\n[bold]Coverage Analysis[/bold] ({' + '.join(phases)})")
282
+ console.print(f" Overall branch coverage: {overall}%")
283
+ console.print(" [dim]Note: CrossHair uses symbolic execution; coverage not applicable.[/dim]")
217
284
 
218
285
  # Exit with combined status
219
286
  all_passed = doctest_passed and crosshair_passed and property_passed
@@ -270,7 +337,7 @@ def map_command(
270
337
  json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
271
338
  ) -> None:
272
339
  """Generate symbol map with reference counts."""
273
- from invar.shell.perception import run_map
340
+ from invar.shell.commands.perception import run_map
274
341
 
275
342
  # Phase 9 P11: Auto-detect agent mode
276
343
  use_json = json_output or _detect_agent_mode()
@@ -286,7 +353,7 @@ def sig_command(
286
353
  json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
287
354
  ) -> None:
288
355
  """Extract signatures from a file or symbol."""
289
- from invar.shell.perception import run_sig
356
+ from invar.shell.commands.perception import run_sig
290
357
 
291
358
  # Phase 9 P11: Auto-detect agent mode
292
359
  use_json = json_output or _detect_agent_mode()
@@ -369,11 +436,12 @@ def rules(
369
436
  console.print(f"\n[dim]{len(rules_list)} rules total. Use --json for full details.[/dim]")
370
437
 
371
438
 
372
- # Import commands from separate modules to reduce file size
373
- from invar.shell.init_cmd import init
374
- from invar.shell.mutate_cmd import mutate # DX-28
375
- from invar.shell.test_cmd import test, verify
376
- from invar.shell.update_cmd import update
439
+ # DX-48b: Import commands from shell/commands/
440
+ from invar.shell.commands.init import init
441
+ from invar.shell.commands.mutate import mutate # DX-28
442
+ from invar.shell.commands.sync_self import sync_self # DX-49
443
+ from invar.shell.commands.test import test, verify
444
+ from invar.shell.commands.update import update
377
445
 
378
446
  app.command()(init)
379
447
  app.command()(update)
@@ -381,6 +449,18 @@ app.command()(test)
381
449
  app.command()(verify)
382
450
  app.command()(mutate) # DX-28: Mutation testing
383
451
 
452
+ # DX-56: Create dev subcommand group for developer commands
453
+ dev_app = typer.Typer(
454
+ name="dev",
455
+ help="Developer commands for Invar project development",
456
+ add_completion=False,
457
+ )
458
+ dev_app.command("sync")(sync_self) # DX-56: renamed from sync-self
459
+ app.add_typer(dev_app)
460
+
461
+ # DX-56: Keep sync-self as alias for backward compatibility (deprecated)
462
+ app.command("sync-self", hidden=True)(sync_self)
463
+
384
464
 
385
465
  if __name__ == "__main__":
386
466
  app()