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
invar/core/rules.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
6
 
7
- from deal import post, pre
7
+ from deal import post
8
8
 
9
9
  from invar.core.contracts import (
10
10
  check_empty_contracts,
@@ -12,11 +12,16 @@ from invar.core.contracts import (
12
12
  check_partial_contract,
13
13
  check_redundant_type_contracts,
14
14
  check_semantic_tautology,
15
+ check_skip_without_reason,
15
16
  )
17
+ from invar.core.entry_points import get_symbol_lines, has_allow_marker, is_entry_point
16
18
  from invar.core.extraction import format_extraction_hint
17
19
  from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
18
20
  from invar.core.must_use import check_must_use
21
+ from invar.core.postcondition_scope import check_postcondition_scope
19
22
  from invar.core.purity import check_impure_calls, check_internal_imports
23
+ from invar.core.review_trigger import check_contract_quality_ratio, check_review_suggested
24
+ from invar.core.shell_architecture import check_shell_pure_logic, check_shell_too_complex
20
25
  from invar.core.suggestions import format_suggestion_for_violation
21
26
  from invar.core.utils import get_excluded_rules
22
27
 
@@ -48,7 +53,6 @@ def _build_size_suggestion(base: str, extraction_hint: str, func_hint: str) -> s
48
53
  return f"{base}{func_hint}" if func_hint else base
49
54
 
50
55
 
51
- @pre(lambda file_info: isinstance(file_info, FileInfo))
52
56
  @post(lambda result: isinstance(result, str))
53
57
  def _get_func_hint(file_info: FileInfo) -> str:
54
58
  """Get top 5 largest functions as hint string."""
@@ -59,7 +63,7 @@ def _get_func_hint(file_info: FileInfo) -> str:
59
63
  return f" Functions: {', '.join(f'{n}({sz}L)' for n, sz in funcs)}" if funcs else ""
60
64
 
61
65
 
62
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
66
+ @post(lambda result: all(v.rule in ("file_size", "file_size_warning") for v in result))
63
67
  def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
64
68
  """
65
69
  Check if file exceeds maximum line count or warning threshold.
@@ -100,13 +104,13 @@ def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
100
104
  return violations
101
105
 
102
106
 
103
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
107
+ @post(lambda result: all(v.rule == "function_size" for v in result))
104
108
  def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
105
109
  """
106
110
  Check if any function exceeds maximum line count.
107
111
 
108
- When use_code_lines is True, uses code_lines (excluding docstring).
109
- When exclude_doctest_lines is True, subtracts doctest lines from count.
112
+ DX-22: Always uses code_lines (excluding docstring) and excludes doctest lines.
113
+ These behaviors were previously optional but are now the default.
110
114
 
111
115
  Examples:
112
116
  >>> from invar.core.models import FileInfo, Symbol, SymbolKind
@@ -121,26 +125,19 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
121
125
  for symbol in file_info.symbols:
122
126
  if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
123
127
  total_lines = symbol.end_line - symbol.line + 1
124
- # Calculate effective line count based on config
125
- if config.use_code_lines and symbol.code_lines is not None:
128
+ # DX-22: Always use code_lines when available (excluding docstring)
129
+ if symbol.code_lines is not None:
126
130
  func_lines = symbol.code_lines
127
131
  line_type = "code lines"
128
132
  else:
129
133
  func_lines = total_lines
130
134
  line_type = "lines"
131
- # Optionally exclude doctest lines
132
- if config.exclude_doctest_lines and symbol.doctest_lines > 0:
135
+ # DX-22: Always exclude doctest lines from size calculation
136
+ if symbol.doctest_lines > 0:
133
137
  func_lines -= symbol.doctest_lines
134
138
  line_type = f"{line_type} (excl. doctest)"
135
139
 
136
140
  if func_lines > config.max_function_lines:
137
- # P19: Show breakdown if doctest lines exist
138
- if symbol.doctest_lines > 0 and not config.exclude_doctest_lines:
139
- code_only = total_lines - symbol.doctest_lines
140
- breakdown = f" ({code_only} code + {symbol.doctest_lines} doctest)"
141
- suggestion = f"Extract helper or set exclude_doctest_lines=true{breakdown}"
142
- else:
143
- suggestion = "Extract helper functions"
144
141
  violations.append(
145
142
  Violation(
146
143
  rule="function_size",
@@ -148,14 +145,14 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
148
145
  file=file_info.path,
149
146
  line=symbol.line,
150
147
  message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {config.max_function_lines})",
151
- suggestion=suggestion,
148
+ suggestion="Extract helper functions",
152
149
  )
153
150
  )
154
151
 
155
152
  return violations
156
153
 
157
154
 
158
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
155
+ @post(lambda result: all(v.rule == "forbidden_import" for v in result))
159
156
  def check_forbidden_imports(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
160
157
  """
161
158
  Check for forbidden imports in Core files.
@@ -199,7 +196,7 @@ def check_forbidden_imports(file_info: FileInfo, config: RuleConfig) -> list[Vio
199
196
  return violations
200
197
 
201
198
 
202
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
199
+ @post(lambda result: all(v.rule == "missing_contract" for v in result))
203
200
  def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
204
201
  """
205
202
  Check that public Core functions have contracts.
@@ -220,9 +217,13 @@ def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
220
217
  if not file_info.is_core or not config.require_contracts:
221
218
  return violations
222
219
 
220
+ source = file_info.source or ""
223
221
  for symbol in file_info.symbols:
224
222
  # Check all functions and methods - agent needs contracts everywhere
225
223
  if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD) and not symbol.contracts:
224
+ # DX-22: Skip if @invar:allow marker present
225
+ if has_allow_marker(symbol, source, "missing_contract"):
226
+ continue
226
227
  kind_name = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
227
228
  suggestion = format_suggestion_for_violation(symbol, "missing_contract")
228
229
  violations.append(
@@ -239,7 +240,7 @@ def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
239
240
  return violations
240
241
 
241
242
 
242
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
243
+ @post(lambda result: all(v.rule == "missing_doctest" for v in result))
243
244
  def check_doctests(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
244
245
  """
245
246
  Check that contracted functions have doctest examples.
@@ -289,12 +290,15 @@ def check_doctests(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
289
290
  return violations
290
291
 
291
292
 
292
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
293
+ @post(lambda result: all(v.rule == "shell_result" for v in result))
293
294
  def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
294
295
  """
295
296
  Check that Shell functions with return values use Result[T, E].
296
297
 
297
- Skips: functions returning None (CLI entry points).
298
+ Skips:
299
+ - Functions returning None (CLI entry points)
300
+ - Generators (Iterator/Generator/AsyncIterator/AsyncGenerator)
301
+ - Entry points (DX-23: framework callbacks like Flask routes, Typer commands)
298
302
 
299
303
  Examples:
300
304
  >>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
@@ -314,23 +318,75 @@ def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violatio
314
318
  # Skip functions with no return type or returning None
315
319
  if "-> None" in symbol.signature or "->" not in symbol.signature:
316
320
  continue
317
- # Skip generators (Iterator/Generator) - acceptable exception per protocol
318
- if "Iterator[" in symbol.signature or "Generator[" in symbol.signature:
321
+ # Skip generators (Iterator/Generator/AsyncIterator/AsyncGenerator) - acceptable per protocol
322
+ # MINOR-11: Added async variants
323
+ if any(
324
+ pattern in symbol.signature
325
+ for pattern in ("Iterator[", "Generator[", "AsyncIterator[", "AsyncGenerator[")
326
+ ):
327
+ continue
328
+ # DX-23: Skip entry points; DX-22: Skip if @invar:allow marker
329
+ if is_entry_point(symbol, file_info.source) or has_allow_marker(symbol, file_info.source, "shell_result"):
319
330
  continue
320
331
  if "Result[" not in symbol.signature:
321
332
  violations.append(
322
333
  Violation(
323
334
  rule="shell_result",
324
- severity=Severity.WARNING,
335
+ severity=Severity.ERROR, # DX-22: Architecture rule
325
336
  file=file_info.path,
326
337
  line=symbol.line,
327
338
  message=f"Shell function '{symbol.name}' should return Result[T, E]",
328
- suggestion="Use Result[T, E] from returns library",
339
+ suggestion="Use Result[T, E], or add: # @invar:allow shell_result: <reason>",
329
340
  )
330
341
  )
331
342
  return violations
332
343
 
333
344
 
345
+ @post(lambda result: all(v.rule == "entry_point_too_thick" for v in result))
346
+ def check_entry_point_thin(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
347
+ """
348
+ Check that entry points are thin (DX-23).
349
+
350
+ Entry points should delegate to Shell functions and not contain
351
+ business logic. They serve as "monad runners" at framework boundaries.
352
+
353
+ Examples:
354
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
355
+ >>> sym = Symbol(name="index", kind=SymbolKind.FUNCTION, line=1, end_line=5)
356
+ >>> source = '@app.route("/")\\ndef index(): pass'
357
+ >>> info = FileInfo(path="shell/web.py", lines=10, symbols=[sym], is_shell=True, source=source)
358
+ >>> check_entry_point_thin(info, RuleConfig())
359
+ []
360
+ """
361
+ violations: list[Violation] = []
362
+ if not file_info.is_shell:
363
+ return violations
364
+
365
+ max_lines = config.entry_max_lines
366
+
367
+ for symbol in file_info.symbols:
368
+ if symbol.kind != SymbolKind.FUNCTION:
369
+ continue
370
+
371
+ # Only check entry points; DX-22: Skip if @invar:allow marker
372
+ if not is_entry_point(symbol, file_info.source) or has_allow_marker(symbol, file_info.source, "entry_point_too_thick"):
373
+ continue
374
+ lines = get_symbol_lines(symbol)
375
+ if lines > max_lines:
376
+ violations.append(
377
+ Violation(
378
+ rule="entry_point_too_thick",
379
+ severity=Severity.ERROR, # DX-22: Architecture rule
380
+ file=file_info.path,
381
+ line=symbol.line,
382
+ message=f"Entry point '{symbol.name}' has {lines} lines (max: {max_lines})",
383
+ suggestion="Move logic to Shell function, or add: # @invar:allow entry_point_too_thick: <reason>",
384
+ )
385
+ )
386
+
387
+ return violations
388
+
389
+
334
390
  @post(lambda result: len(result) > 0)
335
391
  def get_all_rules() -> list[RuleFunc]:
336
392
  """
@@ -347,6 +403,9 @@ def get_all_rules() -> list[RuleFunc]:
347
403
  check_contracts,
348
404
  check_doctests,
349
405
  check_shell_result,
406
+ check_entry_point_thin, # DX-23
407
+ check_shell_pure_logic, # DX-22
408
+ check_shell_too_complex, # DX-22
350
409
  check_internal_imports,
351
410
  check_impure_calls,
352
411
  check_empty_contracts,
@@ -354,7 +413,11 @@ def get_all_rules() -> list[RuleFunc]:
354
413
  check_redundant_type_contracts,
355
414
  check_param_mismatch,
356
415
  check_partial_contract,
416
+ check_postcondition_scope,
357
417
  check_must_use,
418
+ check_skip_without_reason, # DX-28
419
+ check_contract_quality_ratio, # DX-30
420
+ check_review_suggested, # DX-31
358
421
  ]
359
422
 
360
423
 
@@ -398,7 +461,7 @@ def _apply_severity_override(v: Violation, overrides: dict[str, str]) -> Violati
398
461
  )
399
462
 
400
463
 
401
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
464
+ @post(lambda result: all(v.rule and v.file for v in result) if result else True)
402
465
  def check_all_rules(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
403
466
  """
404
467
  Run all rules against a file and collect violations.
@@ -0,0 +1,247 @@
1
+ """
2
+ Shell source analysis helpers for DX-22.
3
+
4
+ Helper functions for analyzing Shell layer code:
5
+ - I/O operation detection
6
+ - Marker pattern detection
7
+ - Branch counting
8
+ - Symbol source extraction
9
+
10
+ Core module: pure logic, no I/O.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import re
17
+ from typing import TYPE_CHECKING
18
+
19
+ from deal import post, pre
20
+
21
+ if TYPE_CHECKING:
22
+ from invar.core.models import Symbol
23
+
24
+ # I/O indicators that mark a function as legitimately in Shell
25
+ IO_INDICATORS: frozenset[str] = frozenset([
26
+ # File operations
27
+ ".read(",
28
+ ".write(",
29
+ ".read_text(",
30
+ ".write_text(",
31
+ ".read_bytes(",
32
+ ".write_bytes(",
33
+ "open(",
34
+ "Path(",
35
+ ".exists()",
36
+ ".is_file()",
37
+ ".is_dir()",
38
+ ".rglob(",
39
+ ".glob(",
40
+ ".iterdir(",
41
+ ".mkdir(",
42
+ ".unlink(",
43
+ "shutil.",
44
+ "tempfile.",
45
+ # Process operations
46
+ "subprocess.",
47
+ "os.system(",
48
+ "os.popen(",
49
+ "os.getenv(",
50
+ "os.environ",
51
+ # Terminal/System
52
+ "sys.stdout",
53
+ "sys.stderr",
54
+ "sys.stdin",
55
+ ".isatty()",
56
+ # Module loading
57
+ "importlib.",
58
+ "exec_module(",
59
+ # Network operations
60
+ "requests.",
61
+ "aiohttp.",
62
+ "httpx.",
63
+ "urllib.",
64
+ # Console output
65
+ "print(",
66
+ "console.",
67
+ "Console(",
68
+ "typer.",
69
+ "click.",
70
+ "rich.",
71
+ # Result wrapping (Shell's primary job)
72
+ "Success(",
73
+ "Failure(",
74
+ "Result[",
75
+ # Database
76
+ "cursor.",
77
+ "connection.",
78
+ "session.",
79
+ # Logging
80
+ "logger.",
81
+ "logging.",
82
+ # Serialization (often to files)
83
+ "json.dump(",
84
+ "json.load(",
85
+ "toml.load(",
86
+ "yaml.load(",
87
+ ])
88
+
89
+ # Marker pattern to exempt functions from complexity check
90
+ COMPLEXITY_MARKER_PATTERN = re.compile(r"#\s*@shell_complexity\s*:")
91
+
92
+ # Marker pattern to exempt functions from pure logic check (for orchestration functions)
93
+ ORCHESTRATION_MARKER_PATTERN = re.compile(r"#\s*@shell_orchestration\s*:")
94
+
95
+
96
+ # @invar:allow missing_contract: Boolean predicate, empty string is valid input
97
+ def has_io_operations(source: str) -> bool:
98
+ """
99
+ Check if source code contains I/O operations.
100
+
101
+ Examples:
102
+ >>> has_io_operations("x = Success(value)")
103
+ True
104
+ >>> has_io_operations("return x + y")
105
+ False
106
+ >>> has_io_operations("path.read_text()")
107
+ True
108
+ >>> has_io_operations("print('hello')")
109
+ True
110
+ """
111
+ return any(indicator in source for indicator in IO_INDICATORS)
112
+
113
+
114
+ @pre(lambda symbol, source: symbol is not None) # Symbol must exist
115
+ def has_orchestration_marker(symbol: Symbol, source: str) -> bool:
116
+ """
117
+ Check if symbol has @shell_orchestration marker comment.
118
+
119
+ Examples:
120
+ >>> from invar.core.models import Symbol, SymbolKind
121
+ >>> sym = Symbol(name="run_phase", kind=SymbolKind.FUNCTION, line=3, end_line=10)
122
+ >>> source = '''
123
+ ... # @shell_orchestration: Coordinates shell modules
124
+ ... def run_phase():
125
+ ... pass
126
+ ... '''
127
+ >>> has_orchestration_marker(sym, source)
128
+ True
129
+
130
+ >>> sym2 = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=3)
131
+ >>> has_orchestration_marker(sym2, "def calc(): pass")
132
+ False
133
+ """
134
+ lines = source.splitlines()
135
+ if not lines:
136
+ return False
137
+
138
+ start_line = max(0, symbol.line - 4)
139
+ end_line = symbol.line
140
+
141
+ context_lines = lines[start_line:end_line]
142
+ context = "\n".join(context_lines)
143
+
144
+ return bool(ORCHESTRATION_MARKER_PATTERN.search(context))
145
+
146
+
147
+ @pre(lambda symbol, source: symbol is not None) # Symbol must exist
148
+ def has_complexity_marker(symbol: Symbol, source: str) -> bool:
149
+ """
150
+ Check if symbol has @shell_complexity marker comment.
151
+
152
+ Examples:
153
+ >>> from invar.core.models import Symbol, SymbolKind
154
+ >>> sym = Symbol(name="load", kind=SymbolKind.FUNCTION, line=3, end_line=10)
155
+ >>> source = '''
156
+ ... # @shell_complexity: Config cascade with fallbacks
157
+ ... def load():
158
+ ... pass
159
+ ... '''
160
+ >>> has_complexity_marker(sym, source)
161
+ True
162
+
163
+ >>> sym2 = Symbol(name="simple", kind=SymbolKind.FUNCTION, line=1, end_line=3)
164
+ >>> has_complexity_marker(sym2, "def simple(): pass")
165
+ False
166
+ """
167
+ lines = source.splitlines()
168
+ if not lines:
169
+ return False
170
+
171
+ # Look at lines before the function definition
172
+ start_line = max(0, symbol.line - 4)
173
+ end_line = symbol.line
174
+
175
+ context_lines = lines[start_line:end_line]
176
+ context = "\n".join(context_lines)
177
+
178
+ return bool(COMPLEXITY_MARKER_PATTERN.search(context))
179
+
180
+
181
+ @post(lambda result: result >= 0) # Branch count is non-negative
182
+ def count_branches(source: str) -> int:
183
+ """
184
+ Count the number of branches in source code.
185
+
186
+ Counts: if, elif, except, for, while, match case, ternary
187
+
188
+ Examples:
189
+ >>> count_branches("if x: pass")
190
+ 1
191
+ >>> count_branches("if x: pass\\nelif y: pass")
192
+ 2
193
+ >>> count_branches("for x in y: pass")
194
+ 1
195
+ >>> count_branches("x = a if b else c")
196
+ 1
197
+ >>> count_branches("pass")
198
+ 0
199
+ >>> count_branches("")
200
+ 0
201
+ """
202
+ try:
203
+ tree = ast.parse(source)
204
+ except SyntaxError:
205
+ return 0
206
+
207
+ count = 0
208
+ for node in ast.walk(tree):
209
+ if isinstance(node, ast.If):
210
+ # ast.walk visits all If nodes including elifs
211
+ count += 1
212
+ elif isinstance(node, ast.For | ast.While | ast.ExceptHandler):
213
+ count += 1
214
+ elif isinstance(node, ast.Match):
215
+ # Count match cases
216
+ count += len(node.cases)
217
+ elif isinstance(node, ast.IfExp):
218
+ # Ternary expression
219
+ count += 1
220
+
221
+ return count
222
+
223
+
224
+ @pre(lambda symbol, file_source: symbol is not None) # Symbol must exist
225
+ def get_symbol_source(symbol: Symbol, file_source: str) -> str:
226
+ """
227
+ Extract the source code for a specific symbol.
228
+
229
+ Examples:
230
+ >>> from invar.core.models import Symbol, SymbolKind
231
+ >>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=2, end_line=4)
232
+ >>> source = '''# comment
233
+ ... def foo():
234
+ ... return 1
235
+ ... '''
236
+ >>> 'def foo' in get_symbol_source(sym, source)
237
+ True
238
+ """
239
+ lines = file_source.splitlines()
240
+ if not lines:
241
+ return ""
242
+
243
+ # Line numbers are 1-indexed
244
+ start = max(0, symbol.line - 1)
245
+ end = min(len(lines), symbol.end_line)
246
+
247
+ return "\n".join(lines[start:end])
@@ -0,0 +1,171 @@
1
+ """
2
+ Shell architecture rules for DX-22.
3
+
4
+ Detects architectural issues in Shell layer:
5
+ - shell_pure_logic: Pure logic that belongs in Core
6
+ - shell_too_complex: Excessive branching complexity
7
+ - shell_complexity_debt: Accumulated complexity warnings
8
+
9
+ Core module: pure logic, no I/O.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from deal import post, pre
15
+
16
+ from invar.core.entry_points import get_symbol_lines, is_entry_point
17
+ from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
18
+ from invar.core.shell_analysis import (
19
+ count_branches,
20
+ get_symbol_source,
21
+ has_complexity_marker,
22
+ has_io_operations,
23
+ has_orchestration_marker,
24
+ )
25
+
26
+
27
+ @post(lambda result: all(v.rule == "shell_pure_logic" for v in result))
28
+ def check_shell_pure_logic(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
29
+ """
30
+ Check that Shell functions contain I/O operations (DX-22).
31
+
32
+ Pure logic belongs in Core where it can be tested with contracts.
33
+ Functions > 5 lines without I/O indicators are flagged as WARNING.
34
+
35
+ Examples:
36
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
37
+ >>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=10)
38
+ >>> source = "def calc(x, y):\\n return x + y"
39
+ >>> info = FileInfo(path="shell/util.py", lines=10, symbols=[sym], is_shell=True, source=source)
40
+ >>> violations = check_shell_pure_logic(info, RuleConfig())
41
+ >>> len(violations) >= 0 # May or may not flag based on line count
42
+ True
43
+ """
44
+ violations: list[Violation] = []
45
+ if not file_info.is_shell:
46
+ return violations
47
+
48
+ for symbol in file_info.symbols:
49
+ if symbol.kind != SymbolKind.FUNCTION:
50
+ continue
51
+
52
+ # Skip small functions (wrappers are fine)
53
+ lines = get_symbol_lines(symbol)
54
+ if lines <= 5:
55
+ continue
56
+
57
+ # Skip entry points (they're handled by DX-23)
58
+ if is_entry_point(symbol, file_info.source):
59
+ continue
60
+
61
+ # Skip if marked with @shell_orchestration (coordinates other shell modules)
62
+ if has_orchestration_marker(symbol, file_info.source):
63
+ continue
64
+
65
+ # Get symbol source and check for I/O
66
+ symbol_source = get_symbol_source(symbol, file_info.source)
67
+ if not has_io_operations(symbol_source):
68
+ violations.append(
69
+ Violation(
70
+ rule="shell_pure_logic",
71
+ severity=Severity.WARNING,
72
+ file=file_info.path,
73
+ line=symbol.line,
74
+ message=f"Shell function '{symbol.name}' has no I/O operations - pure logic belongs in Core",
75
+ suggestion="Move to src/*/core/ and add @pre/@post contracts, or add: # @shell_orchestration: <reason>",
76
+ )
77
+ )
78
+
79
+ return violations
80
+
81
+
82
+ @post(lambda result: all(v.rule == "shell_too_complex" for v in result))
83
+ def check_shell_too_complex(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
84
+ """
85
+ Check that Shell functions don't have excessive branching (DX-22).
86
+
87
+ Complex logic should be in Core where it can be tested.
88
+ Functions exceeding shell_max_branches are flagged as INFO.
89
+
90
+ Use @shell_complexity marker to exempt justified complexity.
91
+
92
+ Examples:
93
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
94
+ >>> sym = Symbol(name="process", kind=SymbolKind.FUNCTION, line=1, end_line=5)
95
+ >>> source = "def process(x):\\n return x"
96
+ >>> info = FileInfo(path="shell/cli.py", lines=5, symbols=[sym], is_shell=True, source=source)
97
+ >>> check_shell_too_complex(info, RuleConfig())
98
+ []
99
+ """
100
+ violations: list[Violation] = []
101
+ if not file_info.is_shell:
102
+ return violations
103
+
104
+ max_branches = config.shell_max_branches
105
+
106
+ for symbol in file_info.symbols:
107
+ if symbol.kind != SymbolKind.FUNCTION:
108
+ continue
109
+
110
+ # Skip if marked with @shell_complexity
111
+ if has_complexity_marker(symbol, file_info.source):
112
+ continue
113
+
114
+ # Skip entry points
115
+ if is_entry_point(symbol, file_info.source):
116
+ continue
117
+
118
+ # Count branches in symbol source
119
+ symbol_source = get_symbol_source(symbol, file_info.source)
120
+ branches = count_branches(symbol_source)
121
+
122
+ if branches > max_branches:
123
+ violations.append(
124
+ Violation(
125
+ rule="shell_too_complex",
126
+ severity=Severity.INFO,
127
+ file=file_info.path,
128
+ line=symbol.line,
129
+ message=f"Shell function '{symbol.name}' has {branches} branches (max: {max_branches})",
130
+ suggestion="Extract logic to Core, or add: # @shell_complexity: <reason>",
131
+ )
132
+ )
133
+
134
+ return violations
135
+
136
+
137
+ @pre(lambda violations, limit: isinstance(violations, list) and limit > 0)
138
+ @post(lambda result: isinstance(result, list))
139
+ def check_complexity_debt(violations: list[Violation], limit: int = 5) -> list[Violation]:
140
+ """
141
+ Check project-level complexity debt (DX-22 Fix-or-Explain).
142
+
143
+ When the project has too many unaddressed shell_too_complex warnings,
144
+ escalate to ERROR to force resolution.
145
+
146
+ Examples:
147
+ >>> from invar.core.models import Violation, Severity
148
+ >>> v1 = Violation(rule="shell_too_complex", severity=Severity.INFO, file="a.py", message="m")
149
+ >>> v2 = Violation(rule="shell_too_complex", severity=Severity.INFO, file="b.py", message="m")
150
+ >>> check_complexity_debt([v1, v2], limit=5)
151
+ []
152
+ >>> many = [Violation(rule="shell_too_complex", severity=Severity.INFO, file=f"{i}.py", message="m") for i in range(6)]
153
+ >>> result = check_complexity_debt(many, limit=5)
154
+ >>> len(result)
155
+ 1
156
+ >>> result[0].severity == Severity.ERROR
157
+ True
158
+ """
159
+ unaddressed = [v for v in violations if v.rule == "shell_too_complex"]
160
+ if len(unaddressed) >= limit:
161
+ return [
162
+ Violation(
163
+ rule="shell_complexity_debt",
164
+ severity=Severity.ERROR,
165
+ file="<project>",
166
+ line=None,
167
+ message=f"Project has {len(unaddressed)} unaddressed complexity warnings (limit: {limit})",
168
+ suggestion="You must address these before proceeding:\n1. Refactor to reduce branches, OR\n2. Add @shell_complexity: markers with justification",
169
+ )
170
+ ]
171
+ return []