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
@@ -0,0 +1,298 @@
1
+ """
2
+ Review trigger detection for DX-30 Visible Workflow and DX-31 Independent Adversarial Reviewer.
3
+
4
+ DX-30: Contract quality ratio check (80% threshold)
5
+ DX-31: Independent review triggers (escape count, coverage, security)
6
+
7
+ Core module: pure logic, no I/O.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from deal import post, pre
15
+
16
+ from invar.core.entry_points import count_escape_hatches
17
+ from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
18
+
19
+ # DX-31: Security-sensitive path patterns that trigger review suggestion
20
+ # Split into two groups to reduce false positives:
21
+
22
+ # Patterns safe to match as substrings (authentication, cryptography are valid matches)
23
+ SECURITY_SUBSTRING_PATTERNS: tuple[str, ...] = (
24
+ "auth", # authentication, authorize, authority
25
+ "crypt", # cryptography, encrypt, decrypt
26
+ "secret",
27
+ "password",
28
+ "credential",
29
+ "permission",
30
+ )
31
+
32
+ # Patterns that must be exact word matches (to avoid keyboard, tokenizer, accessory)
33
+ SECURITY_WORD_PATTERNS: tuple[str, ...] = (
34
+ "token", # not tokenizer
35
+ "key", # not keyboard, monkey
36
+ "session", # not obsession
37
+ "access", # not accessory
38
+ )
39
+
40
+
41
+ @pre(lambda file_info: isinstance(file_info, FileInfo))
42
+ @post(lambda result: isinstance(result, tuple) and len(result) == 3)
43
+ def calculate_contract_ratio(file_info: FileInfo) -> tuple[float, int, int]:
44
+ """
45
+ Calculate contract coverage ratio for a file (DX-31).
46
+
47
+ Returns (ratio, total_functions, with_contracts).
48
+ Only counts public functions (not starting with _).
49
+
50
+ Examples:
51
+ >>> from invar.core.models import Contract, Symbol, SymbolKind
52
+ >>> # No functions
53
+ >>> empty = FileInfo(path="empty.py", lines=10)
54
+ >>> calculate_contract_ratio(empty)
55
+ (1.0, 0, 0)
56
+ >>> # 100% coverage
57
+ >>> c = Contract(kind="pre", expression="x > 0", line=1)
58
+ >>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
59
+ >>> full = FileInfo(path="full.py", lines=10, symbols=[sym])
60
+ >>> calculate_contract_ratio(full)
61
+ (1.0, 1, 1)
62
+ >>> # 0% coverage
63
+ >>> sym_no = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5)
64
+ >>> none = FileInfo(path="none.py", lines=10, symbols=[sym_no])
65
+ >>> calculate_contract_ratio(none)
66
+ (0.0, 1, 0)
67
+ >>> # Private functions ignored
68
+ >>> priv = Symbol(name="_helper", kind=SymbolKind.FUNCTION, line=1, end_line=5)
69
+ >>> private_only = FileInfo(path="priv.py", lines=10, symbols=[priv])
70
+ >>> calculate_contract_ratio(private_only)
71
+ (1.0, 0, 0)
72
+ """
73
+ # Only check public functions (not starting with _)
74
+ # MINOR-9: This excludes dunder methods (__init__, __str__, etc.) which is intentional.
75
+ # Dunder methods are boilerplate; public API methods are the focus of contract coverage.
76
+ functions = [
77
+ s for s in file_info.symbols
78
+ if s.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD) and not s.name.startswith("_")
79
+ ]
80
+
81
+ if not functions:
82
+ return (1.0, 0, 0)
83
+
84
+ total = len(functions)
85
+ with_contracts = sum(1 for f in functions if f.contracts)
86
+ ratio = with_contracts / total
87
+
88
+ return (ratio, total, with_contracts)
89
+
90
+
91
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
92
+ def check_contract_quality_ratio(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
93
+ """
94
+ Check contract coverage ratio in Core files (DX-30).
95
+
96
+ WARNING if less than 80% of public functions have @pre or @post.
97
+ This encourages "Contract before Implement" workflow.
98
+
99
+ Examples:
100
+ >>> # Shell file - no check
101
+ >>> shell_info = FileInfo(path="shell/cli.py", lines=50, is_shell=True)
102
+ >>> check_contract_quality_ratio(shell_info, RuleConfig())
103
+ []
104
+ >>> # Core file with 100% coverage - pass
105
+ >>> from invar.core.models import Contract, Symbol
106
+ >>> c = Contract(kind="pre", expression="x > 0", line=1)
107
+ >>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
108
+ >>> core_ok = FileInfo(path="core/calc.py", lines=50, symbols=[sym], is_core=True)
109
+ >>> check_contract_quality_ratio(core_ok, RuleConfig())
110
+ []
111
+ >>> # Core file with 0% coverage - warning
112
+ >>> sym_no = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5)
113
+ >>> core_bad = FileInfo(path="core/calc.py", lines=50, symbols=[sym_no], is_core=True)
114
+ >>> vs = check_contract_quality_ratio(core_bad, RuleConfig())
115
+ >>> len(vs) == 1 and vs[0].rule == "contract_quality_ratio"
116
+ True
117
+ """
118
+ violations: list[Violation] = []
119
+
120
+ if not file_info.is_core:
121
+ return violations
122
+
123
+ ratio, total, with_contracts = calculate_contract_ratio(file_info)
124
+
125
+ if total == 0:
126
+ return violations
127
+
128
+ if ratio < 0.8:
129
+ pct = int(ratio * 100)
130
+ violations.append(
131
+ Violation(
132
+ rule="contract_quality_ratio",
133
+ severity=Severity.WARNING,
134
+ file=file_info.path,
135
+ line=None,
136
+ message=f"Contract coverage: {pct}% ({with_contracts}/{total}). Target: 80%+",
137
+ suggestion="Add @pre/@post to public functions. See INVAR.md 'Visible Workflow'",
138
+ )
139
+ )
140
+
141
+ return violations
142
+
143
+
144
+ @pre(lambda path: isinstance(path, str))
145
+ @post(lambda result: isinstance(result, bool))
146
+ def is_security_sensitive(path: str) -> bool:
147
+ """
148
+ Check if path indicates security-sensitive code (DX-31).
149
+
150
+ Uses two-tier matching to reduce false positives:
151
+ - Substring matching for unambiguous patterns (auth, crypt, secret, etc.)
152
+ - Word-exact matching for ambiguous patterns (key, token, access, session)
153
+
154
+ Examples:
155
+ >>> # Substring patterns (auth, crypt, secret, password, credential, permission)
156
+ >>> is_security_sensitive("src/auth/login.py")
157
+ True
158
+ >>> is_security_sensitive("src/authentication.py")
159
+ True
160
+ >>> is_security_sensitive("src/core/crypto.py")
161
+ True
162
+ >>> is_security_sensitive("src/utils/helpers.py")
163
+ False
164
+
165
+ >>> # Word-exact patterns (token, key, session, access)
166
+ >>> is_security_sensitive("src/token_handler.py")
167
+ True
168
+ >>> is_security_sensitive("src/api_key.py")
169
+ True
170
+ >>> is_security_sensitive("src/access_control.py")
171
+ True
172
+
173
+ >>> # False positive prevention
174
+ >>> is_security_sensitive("src/tokenizer.py")
175
+ False
176
+ >>> is_security_sensitive("src/keyboard.py")
177
+ False
178
+ >>> is_security_sensitive("src/monkey.py")
179
+ False
180
+ >>> is_security_sensitive("src/accessory.py")
181
+ False
182
+ >>> is_security_sensitive("src/hockey.py")
183
+ False
184
+
185
+ >>> # Edge cases
186
+ >>> is_security_sensitive("")
187
+ False
188
+ """
189
+ if not path:
190
+ return False
191
+
192
+ path_lower = path.lower()
193
+
194
+ # Check substring patterns (safe, low false positive rate)
195
+ if any(pattern in path_lower for pattern in SECURITY_SUBSTRING_PATTERNS):
196
+ return True
197
+
198
+ # Check word-exact patterns (split path into words first)
199
+ words = re.split(r"[/_.\-\\]", path_lower)
200
+ return any(word in SECURITY_WORD_PATTERNS for word in words)
201
+
202
+
203
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
204
+ def check_review_suggested(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
205
+ """
206
+ Suggest independent review when conditions warrant (DX-31).
207
+
208
+ Triggers review suggestion for Core files when:
209
+ - escape_count >= 3: Multiple escape hatches indicate complexity
210
+ - contract_ratio < 50%: Low contract coverage needs review
211
+ - security-sensitive path: Security code needs extra scrutiny
212
+
213
+ Examples:
214
+ >>> from invar.core.models import Contract, Symbol, SymbolKind
215
+ >>> # Shell file - no check
216
+ >>> shell = FileInfo(path="shell/cli.py", lines=50, is_shell=True)
217
+ >>> check_review_suggested(shell, RuleConfig())
218
+ []
219
+ >>> # Core file with no triggers - pass
220
+ >>> c = Contract(kind="pre", expression="x > 0", line=1)
221
+ >>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
222
+ >>> core_ok = FileInfo(path="core/calc.py", lines=50, symbols=[sym], is_core=True)
223
+ >>> check_review_suggested(core_ok, RuleConfig())
224
+ []
225
+ >>> # Security-sensitive path - warning
226
+ >>> auth = FileInfo(path="core/auth/login.py", lines=50, is_core=True, source="")
227
+ >>> vs = check_review_suggested(auth, RuleConfig())
228
+ >>> len(vs) == 1 and vs[0].rule == "review_suggested"
229
+ True
230
+ >>> # Low contract ratio - warning
231
+ >>> sym_no = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5)
232
+ >>> low = FileInfo(path="core/calc.py", lines=50, symbols=[sym_no], is_core=True)
233
+ >>> vs = check_review_suggested(low, RuleConfig())
234
+ >>> len(vs) == 1 and "contract" in vs[0].message.lower()
235
+ True
236
+ >>> # Multiple escape hatches - warning
237
+ >>> source = '''
238
+ ... # @invar:allow rule1: reason1
239
+ ... # @invar:allow rule2: reason2
240
+ ... # @invar:allow rule3: reason3
241
+ ... '''
242
+ >>> escapes = FileInfo(path="core/complex.py", lines=50, is_core=True, source=source, symbols=[sym])
243
+ >>> vs = check_review_suggested(escapes, RuleConfig())
244
+ >>> len(vs) == 1 and "escape" in vs[0].message.lower()
245
+ True
246
+ """
247
+ violations: list[Violation] = []
248
+
249
+ # Only check Core files
250
+ if not file_info.is_core:
251
+ return violations
252
+
253
+ # Trigger 1: Security-sensitive path
254
+ if is_security_sensitive(file_info.path):
255
+ violations.append(
256
+ Violation(
257
+ rule="review_suggested",
258
+ severity=Severity.WARNING,
259
+ file=file_info.path,
260
+ line=None,
261
+ message=f"Security-sensitive file: {file_info.path}",
262
+ suggestion="Consider independent /review before task completion",
263
+ )
264
+ )
265
+ return violations # Only one suggestion per file
266
+
267
+ # Trigger 2: Multiple escape hatches (>= 3)
268
+ source = file_info.source or ""
269
+ escape_count = count_escape_hatches(source)
270
+ if escape_count >= 3:
271
+ violations.append(
272
+ Violation(
273
+ rule="review_suggested",
274
+ severity=Severity.WARNING,
275
+ file=file_info.path,
276
+ line=None,
277
+ message=f"High escape hatch count: {escape_count} @invar:allow markers",
278
+ suggestion="Consider independent /review to validate escape justifications",
279
+ )
280
+ )
281
+ return violations # Only one suggestion per file
282
+
283
+ # Trigger 3: Low contract ratio (< 50%)
284
+ ratio, total, _ = calculate_contract_ratio(file_info)
285
+ if total > 0 and ratio < 0.5:
286
+ pct = int(ratio * 100)
287
+ violations.append(
288
+ Violation(
289
+ rule="review_suggested",
290
+ severity=Severity.WARNING,
291
+ file=file_info.path,
292
+ line=None,
293
+ message=f"Low contract coverage: {pct}% (threshold: 50%)",
294
+ suggestion="Add contracts, or request independent /review to assess quality",
295
+ )
296
+ )
297
+
298
+ return violations
invar/core/rule_meta.py CHANGED
@@ -142,11 +142,43 @@ RULE_META: dict[str, RuleMeta] = {
142
142
  # Shell rules
143
143
  "shell_result": RuleMeta(
144
144
  name="shell_result",
145
- severity=Severity.WARNING,
145
+ severity=Severity.ERROR, # DX-22: Architecture rule, must fix or explain
146
146
  category=RuleCategory.SHELL,
147
147
  detects="Shell function not returning Result[T, E]",
148
148
  cannot_detect=("Result usage correctness", "Error handling quality"),
149
- hint="Wrap return value with Success() or return Failure()",
149
+ hint="Wrap with Success()/Failure(), or add: # @invar:allow shell_result: <reason>",
150
+ ),
151
+ "entry_point_too_thick": RuleMeta(
152
+ name="entry_point_too_thick",
153
+ severity=Severity.ERROR, # DX-22: Architecture rule, must fix or explain
154
+ category=RuleCategory.SHELL,
155
+ detects="Entry point (Flask route, Typer command, etc.) exceeds max lines",
156
+ cannot_detect=("Whether complexity is unavoidable", "Framework constraints"),
157
+ hint="Move logic to Shell function, or add: # @invar:allow entry_point_too_thick: <reason>",
158
+ ),
159
+ "shell_pure_logic": RuleMeta(
160
+ name="shell_pure_logic",
161
+ severity=Severity.WARNING,
162
+ category=RuleCategory.SHELL,
163
+ detects="Shell function with no I/O operations (pure logic belongs in Core)",
164
+ cannot_detect=("Indirect I/O via method calls", "Framework-specific patterns"),
165
+ hint="Move to Core layer and add @pre/@post contracts, or add: # @shell_orchestration: <reason>",
166
+ ),
167
+ "shell_too_complex": RuleMeta(
168
+ name="shell_too_complex",
169
+ severity=Severity.INFO,
170
+ category=RuleCategory.SHELL,
171
+ detects="Shell function with excessive branching complexity",
172
+ cannot_detect=("Whether complexity is justified", "Domain-specific patterns"),
173
+ hint="Extract logic to Core, or add: # @shell_complexity: <reason>",
174
+ ),
175
+ "shell_complexity_debt": RuleMeta(
176
+ name="shell_complexity_debt",
177
+ severity=Severity.ERROR,
178
+ category=RuleCategory.SHELL,
179
+ detects="Project accumulated too many unaddressed complexity warnings (DX-22 Fix-or-Explain)",
180
+ cannot_detect=("Individual function justifications",),
181
+ hint="Address shell_too_complex warnings: refactor OR add @shell_complexity: markers",
150
182
  ),
151
183
  # Documentation rules
152
184
  "missing_doctest": RuleMeta(
@@ -157,6 +189,33 @@ RULE_META: dict[str, RuleMeta] = {
157
189
  cannot_detect=("Doctest quality", "Edge case coverage"),
158
190
  hint="Add >>> examples showing typical usage and edge cases",
159
191
  ),
192
+ # DX-28: Skip abuse prevention
193
+ "skip_without_reason": RuleMeta(
194
+ name="skip_without_reason",
195
+ severity=Severity.WARNING,
196
+ category=RuleCategory.CONTRACTS,
197
+ detects="@skip_property_test used without justification reason",
198
+ cannot_detect=("Whether the reason is valid", "Skip abuse patterns"),
199
+ hint='Add reason: @skip_property_test("category: explanation")',
200
+ ),
201
+ # DX-30: Contract coverage ratio
202
+ "contract_quality_ratio": RuleMeta(
203
+ name="contract_quality_ratio",
204
+ severity=Severity.WARNING,
205
+ category=RuleCategory.CONTRACTS,
206
+ detects="Core file with less than 80% contract coverage on public functions",
207
+ cannot_detect=("Contract quality", "Whether contracts are meaningful"),
208
+ hint="Add @pre/@post to public functions. Use Phase TodoList for complex tasks",
209
+ ),
210
+ # DX-31: Review suggestion trigger
211
+ "review_suggested": RuleMeta(
212
+ name="review_suggested",
213
+ severity=Severity.WARNING,
214
+ category=RuleCategory.DOCS,
215
+ detects="Conditions warranting independent code review (escape count, coverage, security)",
216
+ cannot_detect=("Whether review was performed", "Review quality"),
217
+ hint="Consider independent /review sub-agent before task completion",
218
+ ),
160
219
  }
161
220
 
162
221
 
invar/core/rules.py CHANGED
@@ -12,11 +12,21 @@ 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, # DX-28
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
19
21
  from invar.core.purity import check_impure_calls, check_internal_imports
22
+ from invar.core.review_trigger import (
23
+ check_contract_quality_ratio, # DX-30
24
+ check_review_suggested, # DX-31
25
+ )
26
+ from invar.core.shell_architecture import (
27
+ check_shell_pure_logic,
28
+ check_shell_too_complex,
29
+ )
20
30
  from invar.core.suggestions import format_suggestion_for_violation
21
31
  from invar.core.utils import get_excluded_rules
22
32
 
@@ -105,8 +115,8 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
105
115
  """
106
116
  Check if any function exceeds maximum line count.
107
117
 
108
- When use_code_lines is True, uses code_lines (excluding docstring).
109
- When exclude_doctest_lines is True, subtracts doctest lines from count.
118
+ DX-22: Always uses code_lines (excluding docstring) and excludes doctest lines.
119
+ These behaviors were previously optional but are now the default.
110
120
 
111
121
  Examples:
112
122
  >>> from invar.core.models import FileInfo, Symbol, SymbolKind
@@ -121,26 +131,19 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
121
131
  for symbol in file_info.symbols:
122
132
  if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
123
133
  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:
134
+ # DX-22: Always use code_lines when available (excluding docstring)
135
+ if symbol.code_lines is not None:
126
136
  func_lines = symbol.code_lines
127
137
  line_type = "code lines"
128
138
  else:
129
139
  func_lines = total_lines
130
140
  line_type = "lines"
131
- # Optionally exclude doctest lines
132
- if config.exclude_doctest_lines and symbol.doctest_lines > 0:
141
+ # DX-22: Always exclude doctest lines from size calculation
142
+ if symbol.doctest_lines > 0:
133
143
  func_lines -= symbol.doctest_lines
134
144
  line_type = f"{line_type} (excl. doctest)"
135
145
 
136
146
  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
147
  violations.append(
145
148
  Violation(
146
149
  rule="function_size",
@@ -148,7 +151,7 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
148
151
  file=file_info.path,
149
152
  line=symbol.line,
150
153
  message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {config.max_function_lines})",
151
- suggestion=suggestion,
154
+ suggestion="Extract helper functions",
152
155
  )
153
156
  )
154
157
 
@@ -294,7 +297,10 @@ def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violatio
294
297
  """
295
298
  Check that Shell functions with return values use Result[T, E].
296
299
 
297
- Skips: functions returning None (CLI entry points).
300
+ Skips:
301
+ - Functions returning None (CLI entry points)
302
+ - Generators (Iterator/Generator/AsyncIterator/AsyncGenerator)
303
+ - Entry points (DX-23: framework callbacks like Flask routes, Typer commands)
298
304
 
299
305
  Examples:
300
306
  >>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
@@ -314,23 +320,75 @@ def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violatio
314
320
  # Skip functions with no return type or returning None
315
321
  if "-> None" in symbol.signature or "->" not in symbol.signature:
316
322
  continue
317
- # Skip generators (Iterator/Generator) - acceptable exception per protocol
318
- if "Iterator[" in symbol.signature or "Generator[" in symbol.signature:
323
+ # Skip generators (Iterator/Generator/AsyncIterator/AsyncGenerator) - acceptable per protocol
324
+ # MINOR-11: Added async variants
325
+ if any(
326
+ pattern in symbol.signature
327
+ for pattern in ("Iterator[", "Generator[", "AsyncIterator[", "AsyncGenerator[")
328
+ ):
329
+ continue
330
+ # DX-23: Skip entry points; DX-22: Skip if @invar:allow marker
331
+ if is_entry_point(symbol, file_info.source) or has_allow_marker(symbol, file_info.source, "shell_result"):
319
332
  continue
320
333
  if "Result[" not in symbol.signature:
321
334
  violations.append(
322
335
  Violation(
323
336
  rule="shell_result",
324
- severity=Severity.WARNING,
337
+ severity=Severity.ERROR, # DX-22: Architecture rule
325
338
  file=file_info.path,
326
339
  line=symbol.line,
327
340
  message=f"Shell function '{symbol.name}' should return Result[T, E]",
328
- suggestion="Use Result[T, E] from returns library",
341
+ suggestion="Use Result[T, E], or add: # @invar:allow shell_result: <reason>",
329
342
  )
330
343
  )
331
344
  return violations
332
345
 
333
346
 
347
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
348
+ def check_entry_point_thin(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
349
+ """
350
+ Check that entry points are thin (DX-23).
351
+
352
+ Entry points should delegate to Shell functions and not contain
353
+ business logic. They serve as "monad runners" at framework boundaries.
354
+
355
+ Examples:
356
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
357
+ >>> sym = Symbol(name="index", kind=SymbolKind.FUNCTION, line=1, end_line=5)
358
+ >>> source = '@app.route("/")\\ndef index(): pass'
359
+ >>> info = FileInfo(path="shell/web.py", lines=10, symbols=[sym], is_shell=True, source=source)
360
+ >>> check_entry_point_thin(info, RuleConfig())
361
+ []
362
+ """
363
+ violations: list[Violation] = []
364
+ if not file_info.is_shell:
365
+ return violations
366
+
367
+ max_lines = config.entry_max_lines
368
+
369
+ for symbol in file_info.symbols:
370
+ if symbol.kind != SymbolKind.FUNCTION:
371
+ continue
372
+
373
+ # Only check entry points; DX-22: Skip if @invar:allow marker
374
+ if not is_entry_point(symbol, file_info.source) or has_allow_marker(symbol, file_info.source, "entry_point_too_thick"):
375
+ continue
376
+ lines = get_symbol_lines(symbol)
377
+ if lines > max_lines:
378
+ violations.append(
379
+ Violation(
380
+ rule="entry_point_too_thick",
381
+ severity=Severity.ERROR, # DX-22: Architecture rule
382
+ file=file_info.path,
383
+ line=symbol.line,
384
+ message=f"Entry point '{symbol.name}' has {lines} lines (max: {max_lines})",
385
+ suggestion="Move logic to Shell function, or add: # @invar:allow entry_point_too_thick: <reason>",
386
+ )
387
+ )
388
+
389
+ return violations
390
+
391
+
334
392
  @post(lambda result: len(result) > 0)
335
393
  def get_all_rules() -> list[RuleFunc]:
336
394
  """
@@ -347,6 +405,9 @@ def get_all_rules() -> list[RuleFunc]:
347
405
  check_contracts,
348
406
  check_doctests,
349
407
  check_shell_result,
408
+ check_entry_point_thin, # DX-23
409
+ check_shell_pure_logic, # DX-22
410
+ check_shell_too_complex, # DX-22
350
411
  check_internal_imports,
351
412
  check_impure_calls,
352
413
  check_empty_contracts,
@@ -355,6 +416,9 @@ def get_all_rules() -> list[RuleFunc]:
355
416
  check_param_mismatch,
356
417
  check_partial_contract,
357
418
  check_must_use,
419
+ check_skip_without_reason, # DX-28
420
+ check_contract_quality_ratio, # DX-30
421
+ check_review_suggested, # DX-31
358
422
  ]
359
423
 
360
424