invar-tools 1.0.0__py3-none-any.whl → 1.2.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 (57) hide show
  1. invar/core/contracts.py +75 -5
  2. invar/core/entry_points.py +294 -0
  3. invar/core/format_specs.py +196 -0
  4. invar/core/format_strategies.py +197 -0
  5. invar/core/formatter.py +27 -4
  6. invar/core/hypothesis_strategies.py +47 -5
  7. invar/core/lambda_helpers.py +1 -0
  8. invar/core/models.py +23 -17
  9. invar/core/parser.py +6 -2
  10. invar/core/property_gen.py +81 -40
  11. invar/core/purity.py +10 -4
  12. invar/core/review_trigger.py +298 -0
  13. invar/core/rule_meta.py +61 -2
  14. invar/core/rules.py +83 -19
  15. invar/core/shell_analysis.py +252 -0
  16. invar/core/shell_architecture.py +171 -0
  17. invar/core/suggestions.py +6 -0
  18. invar/core/tautology.py +1 -0
  19. invar/core/utils.py +51 -4
  20. invar/core/verification_routing.py +158 -0
  21. invar/invariant.py +1 -0
  22. invar/mcp/server.py +20 -3
  23. invar/shell/cli.py +59 -31
  24. invar/shell/config.py +259 -10
  25. invar/shell/fs.py +5 -2
  26. invar/shell/git.py +2 -0
  27. invar/shell/guard_helpers.py +78 -3
  28. invar/shell/guard_output.py +100 -24
  29. invar/shell/init_cmd.py +27 -7
  30. invar/shell/mcp_config.py +3 -0
  31. invar/shell/mutate_cmd.py +184 -0
  32. invar/shell/mutation.py +314 -0
  33. invar/shell/perception.py +2 -0
  34. invar/shell/property_tests.py +17 -2
  35. invar/shell/prove.py +35 -3
  36. invar/shell/prove_accept.py +113 -0
  37. invar/shell/prove_fallback.py +148 -46
  38. invar/shell/templates.py +34 -0
  39. invar/shell/test_cmd.py +3 -1
  40. invar/shell/testing.py +6 -17
  41. invar/shell/update_cmd.py +2 -0
  42. invar/templates/CLAUDE.md.template +65 -9
  43. invar/templates/INVAR.md +96 -23
  44. invar/templates/aider.conf.yml.template +16 -14
  45. invar/templates/commands/review.md +200 -0
  46. invar/templates/cursorrules.template +22 -13
  47. invar/templates/examples/contracts.py +3 -1
  48. invar/templates/examples/core_shell.py +3 -1
  49. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/METADATA +81 -15
  50. invar_tools-1.2.0.dist-info/RECORD +77 -0
  51. invar_tools-1.2.0.dist-info/licenses/LICENSE +190 -0
  52. invar_tools-1.2.0.dist-info/licenses/LICENSE-GPL +674 -0
  53. invar_tools-1.2.0.dist-info/licenses/NOTICE +63 -0
  54. invar_tools-1.0.0.dist-info/RECORD +0 -64
  55. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  56. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/WHEEL +0 -0
  57. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/entry_points.txt +0 -0
invar/mcp/server.py CHANGED
@@ -15,7 +15,7 @@ from typing import Any
15
15
  from mcp.server import Server
16
16
  from mcp.types import TextContent, Tool
17
17
 
18
- # Strong instructions for agent behavior (DX-16 + DX-17)
18
+ # Strong instructions for agent behavior (DX-16 + DX-17 + DX-26)
19
19
  INVAR_INSTRUCTIONS = """
20
20
  ## Invar Tool Usage (MANDATORY)
21
21
 
@@ -83,6 +83,8 @@ the MCP tools and may not follow the correct workflow.
83
83
  """
84
84
 
85
85
 
86
+ # @shell_orchestration: MCP tool factory - creates Tool objects
87
+ # @invar:allow shell_result: MCP framework API returns Tool
86
88
  def _get_guard_tool() -> Tool:
87
89
  """Define the invar_guard tool."""
88
90
  return Tool(
@@ -103,6 +105,8 @@ def _get_guard_tool() -> Tool:
103
105
  )
104
106
 
105
107
 
108
+ # @shell_orchestration: MCP tool factory - creates Tool objects
109
+ # @invar:allow shell_result: MCP framework API returns Tool
106
110
  def _get_sig_tool() -> Tool:
107
111
  """Define the invar_sig tool."""
108
112
  return Tool(
@@ -121,6 +125,8 @@ def _get_sig_tool() -> Tool:
121
125
  )
122
126
 
123
127
 
128
+ # @shell_orchestration: MCP tool factory - creates Tool objects
129
+ # @invar:allow shell_result: MCP framework API returns Tool
124
130
  def _get_map_tool() -> Tool:
125
131
  """Define the invar_map tool."""
126
132
  return Tool(
@@ -139,6 +145,8 @@ def _get_map_tool() -> Tool:
139
145
  )
140
146
 
141
147
 
148
+ # @shell_orchestration: MCP server setup - registers handlers with framework
149
+ # @invar:allow shell_result: MCP framework API returns Server
142
150
  def create_server() -> Server:
143
151
  """Create and configure the Invar MCP server."""
144
152
  server = Server(name="invar", version="0.1.0", instructions=INVAR_INSTRUCTIONS)
@@ -158,6 +166,8 @@ def create_server() -> Server:
158
166
  return server
159
167
 
160
168
 
169
+ # @shell_orchestration: MCP handler - subprocess is called inside
170
+ # @invar:allow shell_result: MCP framework API returns list[TextContent]
161
171
  async def _run_guard(args: dict[str, Any]) -> list[TextContent]:
162
172
  """Run invar guard command."""
163
173
  cmd = [sys.executable, "-m", "invar.shell.cli", "guard"]
@@ -170,12 +180,14 @@ async def _run_guard(args: dict[str, Any]) -> list[TextContent]:
170
180
  if args.get("strict", False):
171
181
  cmd.append("--strict")
172
182
 
173
- # Always use JSON output for agent consumption
174
- cmd.append("--json")
183
+ # DX-26: TTY auto-detection - MCP runs in non-TTY, so agent JSON output is automatic
184
+ # No explicit flag needed
175
185
 
176
186
  return await _execute_command(cmd)
177
187
 
178
188
 
189
+ # @shell_orchestration: MCP handler - subprocess is called inside
190
+ # @invar:allow shell_result: MCP framework API returns list[TextContent]
179
191
  async def _run_sig(args: dict[str, Any]) -> list[TextContent]:
180
192
  """Run invar sig command."""
181
193
  target = args.get("target", "")
@@ -186,6 +198,8 @@ async def _run_sig(args: dict[str, Any]) -> list[TextContent]:
186
198
  return await _execute_command(cmd)
187
199
 
188
200
 
201
+ # @shell_orchestration: MCP handler - subprocess is called inside
202
+ # @invar:allow shell_result: MCP framework API returns list[TextContent]
189
203
  async def _run_map(args: dict[str, Any]) -> list[TextContent]:
190
204
  """Run invar map command."""
191
205
  cmd = [sys.executable, "-m", "invar.shell.cli", "map"]
@@ -200,6 +214,8 @@ async def _run_map(args: dict[str, Any]) -> list[TextContent]:
200
214
  return await _execute_command(cmd)
201
215
 
202
216
 
217
+ # @shell_complexity: Command execution with error handling branches
218
+ # @invar:allow shell_result: MCP framework API returns list[TextContent]
203
219
  async def _execute_command(cmd: list[str]) -> list[TextContent]:
204
220
  """Execute a command and return the result."""
205
221
  try:
@@ -229,6 +245,7 @@ async def _execute_command(cmd: list[str]) -> list[TextContent]:
229
245
  return [TextContent(type="text", text=f"Error: {e}")]
230
246
 
231
247
 
248
+ # @shell_orchestration: MCP server entry point - runs async server
232
249
  def run_server() -> None:
233
250
  """Run the Invar MCP server."""
234
251
  import asyncio
invar/shell/cli.py CHANGED
@@ -27,7 +27,7 @@ from invar.core.rules import check_all_rules
27
27
  from invar.core.utils import get_exit_code
28
28
  from invar.shell.config import load_config
29
29
  from invar.shell.fs import scan_project
30
- from invar.shell.guard_output import output_agent, output_json, output_rich
30
+ from invar.shell.guard_output import output_agent, output_rich
31
31
 
32
32
  app = typer.Typer(
33
33
  name="invar",
@@ -37,6 +37,8 @@ app = typer.Typer(
37
37
  console = Console()
38
38
 
39
39
 
40
+ # @shell_orchestration: Statistics helper for CLI guard output
41
+ # @shell_complexity: Iterates symbols checking kind and contracts (4 branches minimal)
40
42
  def _count_core_functions(file_info) -> tuple[int, int]:
41
43
  """Count functions and functions with contracts in a Core file (P24)."""
42
44
  from invar.core.models import SymbolKind
@@ -54,10 +56,13 @@ def _count_core_functions(file_info) -> tuple[int, int]:
54
56
  return (total, with_contracts)
55
57
 
56
58
 
59
+ # @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
57
60
  def _scan_and_check(
58
61
  path: Path, config: RuleConfig, only_files: set[Path] | None = None
59
62
  ) -> Result[GuardReport, str]:
60
63
  """Scan project files and check against rules."""
64
+ from invar.core.shell_architecture import check_complexity_debt
65
+
61
66
  report = GuardReport(files_checked=0)
62
67
  for file_result in scan_project(path, only_files):
63
68
  if isinstance(file_result, Failure):
@@ -70,43 +75,53 @@ def _scan_and_check(
70
75
  report.update_coverage(total, with_contracts)
71
76
  for violation in check_all_rules(file_info, config):
72
77
  report.add_violation(violation)
78
+
79
+ # DX-22: Check project-level complexity debt (Fix-or-Explain enforcement)
80
+ for debt_violation in check_complexity_debt(
81
+ report.violations, config.shell_complexity_debt_limit
82
+ ):
83
+ report.add_violation(debt_violation)
84
+
73
85
  return Success(report)
74
86
 
75
87
 
88
+ # @invar:allow entry_point_too_thick: Main CLI entry point, orchestrates all verification phases
76
89
  @app.command()
77
90
  def guard(
78
91
  path: Path = typer.Argument(
79
92
  Path(), help="Project root directory", exists=True, file_okay=False, dir_okay=True
80
93
  ),
81
94
  strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors"),
95
+ changed: bool = typer.Option(
96
+ False, "--changed", help="Only check git-modified files"
97
+ ),
98
+ static: bool = typer.Option(
99
+ False, "--static", help="Static analysis only, skip all runtime tests"
100
+ ),
101
+ human: bool = typer.Option(
102
+ False, "--human", help="Force human-readable output (for testing/debugging)"
103
+ ),
104
+ # DX-26: Deprecated flags kept for backward compatibility
82
105
  no_strict_pure: bool = typer.Option(
83
- False, "--no-strict-pure", help="Disable purity checks (internal imports, impure calls)"
106
+ False, "--no-strict-pure", hidden=True, help="[Deprecated] Disable purity checks"
84
107
  ),
85
108
  pedantic: bool = typer.Option(
86
- False, "--pedantic", help="Show all violations including off-by-default rules"
109
+ False, "--pedantic", hidden=True, help="[Deprecated] Show off-by-default rules"
87
110
  ),
88
111
  explain: bool = typer.Option(
89
- False, "--explain", help="Show detailed explanations and limitations"
90
- ),
91
- changed: bool = typer.Option(
92
- False, "--changed", help="Only check git-modified files"
112
+ False, "--explain", hidden=True, help="[Deprecated] Show detailed explanations"
93
113
  ),
94
114
  agent: bool = typer.Option(
95
- False, "--agent", help="Output JSON with fix instructions for agents"
115
+ False, "--agent", help="Force JSON output (for inspecting agent format)"
96
116
  ),
97
117
  json_output: bool = typer.Option(
98
- False, "--json", help="Output as JSON (simple format, no fix instructions)"
99
- ),
100
- static: bool = typer.Option(
101
- False, "--static", help="Static analysis only, skip all runtime tests"
118
+ False, "--json", hidden=True, help="[Deprecated] Use TTY auto-detection instead"
102
119
  ),
103
120
  ) -> None:
104
121
  """Check project against Invar architecture rules.
105
122
 
106
123
  Smart Guard: Runs static analysis + doctests + CrossHair + Hypothesis by default.
107
124
  Use --static for quick static-only checks (~0.5s vs ~5s full).
108
-
109
- DX-19: Simplified to 2 levels (Zero decisions).
110
125
  """
111
126
  from invar.shell.guard_helpers import (
112
127
  collect_files_to_check,
@@ -150,17 +165,15 @@ def guard(
150
165
  raise typer.Exit(1)
151
166
  report = scan_result.unwrap()
152
167
 
153
- # Determine output mode
154
- use_agent_output, use_json_output = _determine_output_mode(
155
- json_output, agent
156
- )
168
+ # DX-26: Simplified output mode (TTY auto-detect + --human override)
169
+ use_agent_output = _determine_output_mode(human, agent, json_output)
157
170
 
158
171
  # DX-19: Simplified to 2 levels (STATIC or STANDARD)
159
172
  verification_level = VerificationLevel.STATIC if static else VerificationLevel.STANDARD
160
173
  level_name = "STATIC" if static else "STANDARD"
161
174
 
162
175
  # Show verification level (human mode)
163
- if not use_agent_output and not use_json_output:
176
+ if not use_agent_output:
164
177
  _show_verification_level(verification_level)
165
178
 
166
179
  # Run verification phases
@@ -187,20 +200,19 @@ def guard(
187
200
  checked_files, doctest_passed, static_exit_code
188
201
  )
189
202
 
190
- # Output results
203
+ # DX-26: Unified output (agent JSON or human Rich)
191
204
  if use_agent_output:
192
205
  output_agent(
193
- report, doctest_passed, doctest_output, crosshair_output, level_name,
206
+ report, strict, doctest_passed, doctest_output, crosshair_output, level_name,
194
207
  property_output=property_output,
195
208
  )
196
- elif use_json_output:
197
- output_json(report)
198
209
  else:
199
- output_rich(report, config.strict_pure, changed, pedantic, explain)
210
+ output_rich(report, config.strict_pure, changed, pedantic, explain, static)
200
211
  output_verification_status(
201
212
  verification_level, static_exit_code, doctest_passed,
202
213
  doctest_output, crosshair_output, explain,
203
214
  property_output=property_output,
215
+ strict=strict,
204
216
  )
205
217
 
206
218
  # Exit with combined status
@@ -209,13 +221,26 @@ def guard(
209
221
  raise typer.Exit(final_exit)
210
222
 
211
223
 
212
- def _determine_output_mode(json_output: bool, agent: bool) -> tuple[bool, bool]:
213
- """Determine output mode based on flags and context."""
214
- if json_output:
215
- return False, True
216
- if agent or _detect_agent_mode():
217
- return True, False
218
- return False, False
224
+ # @shell_orchestration: Output mode decision helper for CLI
225
+ def _determine_output_mode(human: bool, agent: bool = False, json_output: bool = False) -> bool:
226
+ """Determine if agent JSON output should be used (DX-26).
227
+
228
+ DX-26: TTY auto-detection with --human override.
229
+ - --human flag → human output (for testing/debugging)
230
+ - TTY (terminal) → human output
231
+ - Non-TTY (pipe/redirect) → agent JSON output
232
+ - Deprecated --agent/--json flags → still work for backward compat
233
+ """
234
+ # --human flag always forces human output
235
+ if human:
236
+ return False # use_agent = False
237
+
238
+ # Deprecated flags (backward compat)
239
+ if json_output or agent:
240
+ return True # use_agent = True
241
+
242
+ # TTY auto-detection
243
+ return _detect_agent_mode() # Returns True if non-TTY
219
244
 
220
245
 
221
246
  def _show_verification_level(verification_level) -> None:
@@ -271,6 +296,7 @@ def sig_command(
271
296
  raise typer.Exit(1)
272
297
 
273
298
 
299
+ # @invar:allow entry_point_too_thick: Rules display with filtering and dual output modes
274
300
  @app.command()
275
301
  def rules(
276
302
  json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
@@ -345,6 +371,7 @@ def rules(
345
371
 
346
372
  # Import commands from separate modules to reduce file size
347
373
  from invar.shell.init_cmd import init
374
+ from invar.shell.mutate_cmd import mutate # DX-28
348
375
  from invar.shell.test_cmd import test, verify
349
376
  from invar.shell.update_cmd import update
350
377
 
@@ -352,6 +379,7 @@ app.command()(init)
352
379
  app.command()(update)
353
380
  app.command()(test)
354
381
  app.command()(verify)
382
+ app.command()(mutate) # DX-28: Mutation testing
355
383
 
356
384
 
357
385
  if __name__ == "__main__":
invar/shell/config.py CHANGED
@@ -8,11 +8,15 @@ Configuration sources (priority order):
8
8
  2. invar.toml [guard]
9
9
  3. .invar/config.toml [guard]
10
10
  4. Built-in defaults
11
+
12
+ DX-22: Added content-based auto-detection for Core/Shell classification.
11
13
  """
12
14
 
13
15
  from __future__ import annotations
14
16
 
17
+ import ast
15
18
  import tomllib
19
+ from enum import Enum
16
20
  from typing import TYPE_CHECKING, Any, Literal
17
21
 
18
22
  from returns.result import Failure, Result, Success
@@ -25,12 +29,223 @@ from invar.core.utils import (
25
29
  parse_guard_config,
26
30
  )
27
31
 
32
+
33
+ class ModuleType(Enum):
34
+ """DX-22: Module type for auto-detection."""
35
+
36
+ CORE = "core"
37
+ SHELL = "shell"
38
+ UNKNOWN = "unknown"
39
+
40
+
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
+ )
63
+
64
+ # Contract decorator names
65
+ _CONTRACT_DECORATORS = frozenset(["pre", "post", "invariant"])
66
+
67
+ # Result monad types
68
+ _RESULT_TYPES = frozenset(["Result", "Success", "Failure"])
69
+
70
+
71
+ # @shell_orchestration: AST analysis helpers for module classification
72
+ def _has_contract_decorators(tree: ast.Module) -> bool:
73
+ """
74
+ Check if AST contains @pre/@post contract decorators.
75
+
76
+ Uses AST to only detect real decorators, not strings in docstrings.
77
+
78
+ Examples:
79
+ >>> import ast
80
+ >>> tree = ast.parse("@pre(lambda x: x > 0)\\ndef foo(x): pass")
81
+ >>> _has_contract_decorators(tree)
82
+ True
83
+ >>> tree = ast.parse("def foo():\\n '''>>> @pre(x)'''\\n pass")
84
+ >>> _has_contract_decorators(tree)
85
+ False
86
+ """
87
+ for node in ast.walk(tree):
88
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
89
+ for decorator in node.decorator_list:
90
+ # @pre(...) or @post(...)
91
+ if isinstance(decorator, ast.Call):
92
+ func = decorator.func
93
+ if isinstance(func, ast.Name) and func.id in _CONTRACT_DECORATORS:
94
+ return True
95
+ if isinstance(func, ast.Attribute) and func.attr in _CONTRACT_DECORATORS:
96
+ return True
97
+ # @pre (without call - rare but possible)
98
+ elif isinstance(decorator, ast.Name) and decorator.id in _CONTRACT_DECORATORS:
99
+ return True
100
+ return False
101
+
102
+
103
+ # @shell_orchestration: AST analysis helper for module classification
104
+ def _has_io_imports(tree: ast.Module) -> bool:
105
+ """
106
+ Check if AST contains imports of I/O libraries.
107
+
108
+ Examples:
109
+ >>> import ast
110
+ >>> tree = ast.parse("import os")
111
+ >>> _has_io_imports(tree)
112
+ True
113
+ >>> tree = ast.parse("from pathlib import Path")
114
+ >>> _has_io_imports(tree)
115
+ True
116
+ >>> tree = ast.parse("import json")
117
+ >>> _has_io_imports(tree)
118
+ False
119
+ >>> tree = ast.parse("def foo():\\n '''import os'''\\n pass")
120
+ >>> _has_io_imports(tree)
121
+ False
122
+ """
123
+ for node in ast.walk(tree):
124
+ if isinstance(node, ast.Import):
125
+ for alias in node.names:
126
+ lib = alias.name.split(".")[0]
127
+ if lib in _IO_LIBRARIES:
128
+ return True
129
+ elif isinstance(node, ast.ImportFrom) and node.module:
130
+ lib = node.module.split(".")[0]
131
+ if lib in _IO_LIBRARIES:
132
+ return True
133
+ return False
134
+
135
+
136
+ # @shell_orchestration: AST analysis helper for module classification
137
+ def _has_result_types(tree: ast.Module) -> bool:
138
+ """
139
+ Check if AST contains Result/Success/Failure usage.
140
+
141
+ Checks:
142
+ - Return type annotations: -> Result[T, E]
143
+ - Imports: from returns.result import Success
144
+ - Function calls: Success(...), Failure(...)
145
+
146
+ Examples:
147
+ >>> import ast
148
+ >>> tree = ast.parse("from returns.result import Success")
149
+ >>> _has_result_types(tree)
150
+ True
151
+ >>> tree = ast.parse("def foo() -> Result[int, str]: pass")
152
+ >>> _has_result_types(tree)
153
+ True
154
+ >>> tree = ast.parse("return Success(42)")
155
+ >>> _has_result_types(tree)
156
+ True
157
+ >>> tree = ast.parse("def foo():\\n '''Success'''\\n pass")
158
+ >>> _has_result_types(tree)
159
+ False
160
+ """
161
+ for node in ast.walk(tree):
162
+ # Check imports: from returns.result import Success
163
+ if isinstance(node, ast.ImportFrom):
164
+ if node.module and "returns" in node.module:
165
+ for alias in node.names:
166
+ if alias.name in _RESULT_TYPES:
167
+ return True
168
+ # Check function calls: Success(...), Failure(...)
169
+ elif isinstance(node, ast.Call):
170
+ if isinstance(node.func, ast.Name) and node.func.id in _RESULT_TYPES:
171
+ return True
172
+ # Check type annotations: -> Result[T, E]
173
+ elif isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
174
+ if node.returns:
175
+ ann = node.returns
176
+ if isinstance(ann, ast.Subscript):
177
+ if isinstance(ann.value, ast.Name) and ann.value.id == "Result":
178
+ return True
179
+ elif isinstance(ann, ast.Name) and ann.id in _RESULT_TYPES:
180
+ return True
181
+ return False
182
+
183
+
184
+ # @shell_complexity: Classification decision tree requires multiple conditions
185
+ def auto_detect_module_type(source: str, file_path: str = "") -> ModuleType:
186
+ """
187
+ Automatically detect module type from source content using AST.
188
+
189
+ DX-22: Content-based classification when path-based is inconclusive.
190
+ Uses AST parsing to avoid false positives from docstrings/comments.
191
+
192
+ Priority:
193
+ 1. Path convention (**/core/** or **/shell/**)
194
+ 2. Content features via AST (contracts, Result types, I/O imports)
195
+
196
+ Args:
197
+ source: Python source code as string
198
+ file_path: Optional file path for path-based hints
199
+
200
+ Returns:
201
+ ModuleType indicating Core, Shell, or Unknown
202
+
203
+ Examples:
204
+ >>> auto_detect_module_type("@pre(lambda x: x > 0)\\ndef foo(x): pass")
205
+ <ModuleType.CORE: 'core'>
206
+ >>> auto_detect_module_type("from returns.result import Success\\ndef load(): return Success('ok')")
207
+ <ModuleType.SHELL: 'shell'>
208
+ >>> auto_detect_module_type("def helper(): pass")
209
+ <ModuleType.UNKNOWN: 'unknown'>
210
+ >>> auto_detect_module_type("def foo():\\n '''>>> @pre(x)'''\\n pass")
211
+ <ModuleType.UNKNOWN: 'unknown'>
212
+ """
213
+ # Priority 1: Path convention
214
+ if file_path:
215
+ path_lower = file_path.lower()
216
+ if "/core/" in path_lower or path_lower.endswith("/core"):
217
+ return ModuleType.CORE
218
+ if "/shell/" in path_lower or path_lower.endswith("/shell"):
219
+ return ModuleType.SHELL
220
+
221
+ # Priority 2: Content features via AST
222
+ try:
223
+ tree = ast.parse(source)
224
+ except SyntaxError:
225
+ return ModuleType.UNKNOWN
226
+
227
+ has_contracts = _has_contract_decorators(tree)
228
+ has_io = _has_io_imports(tree)
229
+ has_result = _has_result_types(tree)
230
+
231
+ # Core: has contracts AND no I/O
232
+ if has_contracts and not has_io:
233
+ return ModuleType.CORE
234
+
235
+ # Shell: has I/O or Result types
236
+ if has_io or has_result:
237
+ return ModuleType.SHELL
238
+
239
+ # Unknown: neither clear pattern
240
+ return ModuleType.UNKNOWN
241
+
28
242
  if TYPE_CHECKING:
29
243
  from pathlib import Path
30
244
 
31
245
  ConfigSource = Literal["pyproject", "invar", "invar_dir", "default"]
32
246
 
33
247
 
248
+ # @shell_complexity: Config cascade checks multiple sources with fallback
34
249
  def _find_config_source(project_root: Path) -> Result[tuple[Path | None, ConfigSource], str]:
35
250
  """
36
251
  Find the first available config file.
@@ -76,6 +291,7 @@ def _read_toml(path: Path) -> Result[dict[str, Any], str]:
76
291
  return Failure(f"Failed to read {path.name}: {e}")
77
292
 
78
293
 
294
+ # @shell_complexity: Config loading with multiple sources and parse error handling
79
295
  def load_config(project_root: Path) -> Result[RuleConfig, str]:
80
296
  """
81
297
  Load Invar configuration from available sources.
@@ -139,6 +355,9 @@ _DEFAULT_EXCLUDE_PATHS = [
139
355
  "dist",
140
356
  "build",
141
357
  ".tox",
358
+ # Templates and examples are documentation, not enforced code
359
+ "templates",
360
+ ".invar/examples",
142
361
  ]
143
362
 
144
363
 
@@ -206,11 +425,19 @@ def get_exclude_paths(project_root: Path) -> Result[list[str], str]:
206
425
  return Success(guard_config.get("exclude_paths", _DEFAULT_EXCLUDE_PATHS.copy()))
207
426
 
208
427
 
209
- def classify_file(file_path: str, project_root: Path) -> Result[tuple[bool, bool], str]:
428
+ # @invar:allow entry_point_too_thick: False positive - .get() matches router.get pattern
429
+ def classify_file(
430
+ file_path: str, project_root: Path, source: str = ""
431
+ ) -> Result[tuple[bool, bool], str]:
210
432
  """
211
433
  Classify a file as Core, Shell, or neither.
212
434
 
213
- Priority: patterns > paths > uncategorized.
435
+ DX-22 Part 5: Priority order is patterns > paths > content > uncategorized.
436
+
437
+ Args:
438
+ file_path: Relative path to the file
439
+ project_root: Project root directory
440
+ source: Optional source content for content-based detection
214
441
 
215
442
  Examples:
216
443
  >>> import tempfile
@@ -220,18 +447,32 @@ def classify_file(file_path: str, project_root: Path) -> Result[tuple[bool, bool
220
447
  ... result = classify_file("src/core/logic.py", root)
221
448
  ... result.unwrap()[0]
222
449
  True
450
+ >>> classify_file("lib/utils.py", Path("."), "@pre(lambda x: x > 0)\\ndef foo(x): pass").unwrap()
451
+ (True, False)
452
+ >>> classify_file("lib/io.py", Path("."), "def read() -> Result[str, str]: return Success('ok')").unwrap()
453
+ (False, True)
223
454
  """
224
455
  pattern_result = get_pattern_classification(project_root)
225
- core_patterns, shell_patterns = (
226
- pattern_result.unwrap() if isinstance(pattern_result, Success) else ([], [])
227
- )
456
+ if isinstance(pattern_result, Success):
457
+ core_patterns, shell_patterns = pattern_result.unwrap()
458
+ else:
459
+ # Log warning about config error, use defaults
460
+ import logging
461
+ logging.getLogger(__name__).debug(
462
+ "Pattern classification failed: %s, using defaults", pattern_result.failure()
463
+ )
464
+ core_patterns, shell_patterns = ([], [])
228
465
 
229
466
  path_result = get_path_classification(project_root)
230
- core_paths, shell_paths = (
231
- path_result.unwrap()
232
- if isinstance(path_result, Success)
233
- else (_DEFAULT_CORE_PATHS, _DEFAULT_SHELL_PATHS)
234
- )
467
+ if isinstance(path_result, Success):
468
+ core_paths, shell_paths = path_result.unwrap()
469
+ else:
470
+ # Log warning about config error, use defaults
471
+ import logging
472
+ logging.getLogger(__name__).debug(
473
+ "Path classification failed: %s, using defaults", path_result.failure()
474
+ )
475
+ core_paths, shell_paths = (_DEFAULT_CORE_PATHS, _DEFAULT_SHELL_PATHS)
235
476
 
236
477
  # Priority 1: Pattern-based classification
237
478
  if core_patterns and matches_pattern(file_path, core_patterns):
@@ -245,4 +486,12 @@ def classify_file(file_path: str, project_root: Path) -> Result[tuple[bool, bool
245
486
  if matches_path_prefix(file_path, shell_paths):
246
487
  return Success((False, True))
247
488
 
489
+ # Priority 3: Content-based auto-detection (DX-22 Part 5)
490
+ if source:
491
+ module_type = auto_detect_module_type(source, file_path)
492
+ if module_type == ModuleType.CORE:
493
+ return Success((True, False))
494
+ if module_type == ModuleType.SHELL:
495
+ return Success((False, True))
496
+
248
497
  return Success((False, False))
invar/shell/fs.py CHANGED
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
19
19
  from pathlib import Path
20
20
 
21
21
 
22
+ # @shell_complexity: Recursive file discovery with gitignore and exclusions
22
23
  def discover_python_files(
23
24
  project_root: Path,
24
25
  exclude_patterns: list[str] | None = None,
@@ -52,6 +53,7 @@ def discover_python_files(
52
53
  yield py_file
53
54
 
54
55
 
56
+ # @shell_complexity: File reading with AST parsing and error handling
55
57
  def read_and_parse_file(file_path: Path, project_root: Path) -> Result[FileInfo, str]:
56
58
  """
57
59
  Read a Python file and parse it into FileInfo.
@@ -79,8 +81,8 @@ def read_and_parse_file(file_path: Path, project_root: Path) -> Result[FileInfo,
79
81
  if file_info is None:
80
82
  return Failure(f"Syntax error in {file_path}")
81
83
 
82
- # Classify as Core or Shell based on patterns and paths
83
- classify_result = classify_file(relative_path, project_root)
84
+ # Classify as Core or Shell based on patterns, paths, and content (DX-22 Part 5)
85
+ classify_result = classify_file(relative_path, project_root, file_info.source)
84
86
  file_info.is_core, file_info.is_shell = (
85
87
  classify_result.unwrap() if isinstance(classify_result, Success) else (False, False)
86
88
  )
@@ -88,6 +90,7 @@ def read_and_parse_file(file_path: Path, project_root: Path) -> Result[FileInfo,
88
90
  return Success(file_info)
89
91
 
90
92
 
93
+ # @shell_complexity: Project scanning with exclusions and error handling
91
94
  def scan_project(
92
95
  project_root: Path,
93
96
  only_files: set[Path] | None = None,
invar/shell/git.py CHANGED
@@ -28,6 +28,7 @@ def _run_git(args: list[str], cwd: Path) -> Result[str, str]:
28
28
  return Failure(f"Git error: {e}")
29
29
 
30
30
 
31
+ # @shell_orchestration: Helper for git output parsing, tightly coupled to Shell
31
32
  def _parse_py_files(output: str, project_root: Path) -> set[Path]:
32
33
  """Parse git output and return Python file paths."""
33
34
  files: set[Path] = set()
@@ -37,6 +38,7 @@ def _parse_py_files(output: str, project_root: Path) -> set[Path]:
37
38
  return files
38
39
 
39
40
 
41
+ # @shell_complexity: Git operations require multiple subprocess calls with error handling
40
42
  def get_changed_files(project_root: Path) -> Result[set[Path], str]:
41
43
  """
42
44
  Get Python files modified according to git (staged, unstaged, untracked).