invar-tools 1.2.0__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +10 -10
  3. invar/core/entry_points.py +105 -32
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +1 -2
  6. invar/core/formatter.py +6 -7
  7. invar/core/hypothesis_strategies.py +5 -7
  8. invar/core/inspect.py +1 -1
  9. invar/core/lambda_helpers.py +3 -3
  10. invar/core/models.py +7 -1
  11. invar/core/must_use.py +2 -1
  12. invar/core/parser.py +7 -4
  13. invar/core/postcondition_scope.py +128 -0
  14. invar/core/property_gen.py +8 -5
  15. invar/core/purity.py +3 -3
  16. invar/core/purity_heuristics.py +5 -9
  17. invar/core/references.py +8 -6
  18. invar/core/review_trigger.py +78 -6
  19. invar/core/rule_meta.py +8 -0
  20. invar/core/rules.py +18 -19
  21. invar/core/shell_analysis.py +5 -10
  22. invar/core/shell_architecture.py +2 -2
  23. invar/core/strategies.py +7 -14
  24. invar/core/suggestions.py +86 -0
  25. invar/core/sync_helpers.py +238 -0
  26. invar/core/tautology.py +102 -37
  27. invar/core/template_parser.py +467 -0
  28. invar/core/timeout_inference.py +4 -7
  29. invar/core/utils.py +13 -15
  30. invar/core/verification_routing.py +4 -7
  31. invar/mcp/server.py +100 -17
  32. invar/shell/commands/__init__.py +11 -0
  33. invar/shell/{cli.py → commands/guard.py} +94 -14
  34. invar/shell/{init_cmd.py → commands/init.py} +179 -27
  35. invar/shell/commands/merge.py +256 -0
  36. invar/shell/commands/sync_self.py +113 -0
  37. invar/shell/commands/template_sync.py +366 -0
  38. invar/shell/commands/update.py +48 -0
  39. invar/shell/config.py +12 -24
  40. invar/shell/coverage.py +351 -0
  41. invar/shell/guard_helpers.py +38 -17
  42. invar/shell/guard_output.py +7 -1
  43. invar/shell/property_tests.py +58 -22
  44. invar/shell/prove/__init__.py +9 -0
  45. invar/shell/{prove.py → prove/crosshair.py} +40 -33
  46. invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
  47. invar/shell/subprocess_env.py +393 -0
  48. invar/shell/template_engine.py +345 -0
  49. invar/shell/templates.py +19 -0
  50. invar/shell/testing.py +71 -20
  51. invar/templates/CLAUDE.md.template +38 -17
  52. invar/templates/aider.conf.yml.template +2 -2
  53. invar/templates/commands/{review.md → audit.md} +20 -82
  54. invar/templates/commands/guard.md +77 -0
  55. invar/templates/config/CLAUDE.md.jinja +206 -0
  56. invar/templates/config/context.md.jinja +92 -0
  57. invar/templates/config/pre-commit.yaml.jinja +44 -0
  58. invar/templates/context.md.template +33 -0
  59. invar/templates/cursorrules.template +7 -4
  60. invar/templates/examples/README.md +2 -0
  61. invar/templates/examples/conftest.py +3 -0
  62. invar/templates/examples/contracts.py +5 -5
  63. invar/templates/examples/core_shell.py +11 -7
  64. invar/templates/examples/workflow.md +81 -0
  65. invar/templates/manifest.toml +137 -0
  66. invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
  67. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  68. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  69. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  70. invar/templates/skills/review/SKILL.md.jinja +125 -0
  71. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
  72. invar_tools-1.3.0.dist-info/RECORD +95 -0
  73. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  74. invar/contracts.py +0 -152
  75. invar/decorators.py +0 -94
  76. invar/invariant.py +0 -58
  77. invar/resource.py +0 -99
  78. invar/shell/update_cmd.py +0 -193
  79. invar_tools-1.2.0.dist-info/RECORD +0 -77
  80. invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
  81. /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
  82. /invar/shell/{perception.py → commands/perception.py} +0 -0
  83. /invar/shell/{test_cmd.py → commands/test.py} +0 -0
  84. /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
  85. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  86. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
  87. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
  88. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
  89. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,128 @@
1
+ """Postcondition scope validation for Guard. No I/O operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+
7
+ from deal import post, pre
8
+
9
+ from invar.core.lambda_helpers import (
10
+ extract_func_param_names,
11
+ extract_used_names,
12
+ find_lambda,
13
+ )
14
+ from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
15
+
16
+
17
+ @pre(lambda node: isinstance(node, ast.expr))
18
+ @post(lambda result: isinstance(result, set))
19
+ def _extract_comprehension_bound_names(node: ast.expr) -> set[str]:
20
+ """Extract names bound by comprehensions (generator expressions, list comps, etc.).
21
+
22
+ Examples:
23
+ >>> import ast
24
+ >>> expr = ast.parse("all(v.rule for v in result)", mode="eval").body
25
+ >>> sorted(_extract_comprehension_bound_names(expr))
26
+ ['v']
27
+ """
28
+ bound: set[str] = set()
29
+ for child in ast.walk(node):
30
+ if isinstance(child, ast.comprehension):
31
+ # Extract bound variable(s) from the target
32
+ for name in ast.walk(child.target):
33
+ if isinstance(name, ast.Name):
34
+ bound.add(name.id)
35
+ return bound
36
+
37
+
38
+ @post(lambda result: isinstance(result, set))
39
+ def _extract_used_names_from_expression(expression: str) -> set[str]:
40
+ """Extract free variable names from a lambda expression string.
41
+
42
+ Excludes comprehension-bound variables like 'v' in 'all(v.x for v in result)'.
43
+
44
+ Examples:
45
+ >>> sorted(_extract_used_names_from_expression("lambda result: len(result) > 0"))
46
+ ['len', 'result']
47
+ >>> _extract_used_names_from_expression("lambda x: x > 0")
48
+ {'x'}
49
+ >>> _extract_used_names_from_expression("not a lambda")
50
+ set()
51
+ >>> sorted(_extract_used_names_from_expression("lambda result: all(v.rule for v in result)"))
52
+ ['all', 'result']
53
+ """
54
+ if not expression.strip() or "lambda" not in expression:
55
+ return set()
56
+ try:
57
+ tree = ast.parse(expression, mode="eval")
58
+ lambda_node = find_lambda(tree)
59
+ if lambda_node is None:
60
+ return set()
61
+ # Extract all names from lambda body
62
+ all_names = extract_used_names(lambda_node.body)
63
+ # Subtract comprehension-bound names
64
+ bound_names = _extract_comprehension_bound_names(lambda_node.body)
65
+ return all_names - bound_names
66
+ except (SyntaxError, TypeError, ValueError):
67
+ return set()
68
+
69
+
70
+ @post(lambda result: all(v.rule == "postcondition_scope_error" for v in result))
71
+ def check_postcondition_scope(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
72
+ """Check @post lambdas for references to function parameters.
73
+
74
+ @post lambdas cannot access function parameters (except via 'result').
75
+ They can access module-level imports and builtins via closure.
76
+
77
+ Examples:
78
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract, RuleConfig
79
+ >>> c = Contract(kind="post", expression="lambda result: result > x", line=5)
80
+ >>> s = Symbol(name="f", kind=SymbolKind.FUNCTION, line=1, end_line=10,
81
+ ... signature="(x: int) -> int", contracts=[c])
82
+ >>> info = FileInfo(path="test.py", lines=10, symbols=[s], is_core=True)
83
+ >>> vs = check_postcondition_scope(info, RuleConfig())
84
+ >>> len(vs) == 1 and "x" in vs[0].message
85
+ True
86
+ >>> # Module imports are allowed
87
+ >>> c2 = Contract(kind="post", expression="lambda result: isinstance(result, Violation)", line=5)
88
+ >>> s2 = Symbol(name="g", kind=SymbolKind.FUNCTION, line=1, end_line=10,
89
+ ... signature="(data: str) -> Violation", contracts=[c2])
90
+ >>> info2 = FileInfo(path="test.py", lines=10, symbols=[s2], is_core=True)
91
+ >>> check_postcondition_scope(info2, RuleConfig())
92
+ []
93
+ """
94
+ violations: list[Violation] = []
95
+ if not file_info.is_core:
96
+ return violations
97
+
98
+ for symbol in file_info.symbols:
99
+ if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
100
+ continue
101
+
102
+ # Extract function parameter names from signature
103
+ param_names = extract_func_param_names(symbol.signature) or []
104
+ # Exclude 'self', 'cls', and 'result' (valid in @post)
105
+ invalid_params = set(param_names) - {"self", "cls", "result"}
106
+
107
+ for contract in symbol.contracts:
108
+ if contract.kind != "post":
109
+ continue
110
+
111
+ used_names = _extract_used_names_from_expression(contract.expression)
112
+ # Check if any function parameters are used (they shouldn't be)
113
+ param_references = used_names & invalid_params
114
+
115
+ if param_references:
116
+ kind = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
117
+ violations.append(
118
+ Violation(
119
+ rule="postcondition_scope_error",
120
+ severity=Severity.ERROR,
121
+ file=file_info.path,
122
+ line=contract.line,
123
+ message=f"{kind} '{symbol.name}' @post references function parameters: {', '.join(sorted(param_references))}",
124
+ suggestion="@post lambdas can only use 'result', not function parameters",
125
+ )
126
+ )
127
+
128
+ return violations
@@ -145,7 +145,7 @@ def generate_property_test(func: Callable) -> GeneratedTest | None:
145
145
  )
146
146
 
147
147
 
148
- @pre(lambda func_name, strategies: isinstance(func_name, str) and isinstance(strategies, dict))
148
+ @pre(lambda func_name, strategies: len(func_name) > 0 and len(strategies) > 0)
149
149
  @post(lambda result: isinstance(result, str) and "@given" in result)
150
150
  def _generate_test_code(func_name: str, strategies: dict[str, str]) -> str:
151
151
  """
@@ -194,7 +194,10 @@ def _check_decorator_contracts(dec: ast.Call) -> tuple[bool, bool]:
194
194
  return has_pre, has_post
195
195
 
196
196
 
197
- @pre(lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)))
197
+ @pre(lambda node: (
198
+ isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and
199
+ hasattr(node, 'decorator_list')
200
+ ))
198
201
  @post(lambda result: isinstance(result, tuple) and len(result) == 2)
199
202
  def _get_function_contracts(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[bool, bool]:
200
203
  """Check function decorators for contracts, return (has_pre, has_post).
@@ -331,7 +334,7 @@ def build_test_function(
331
334
  return property_test
332
335
 
333
336
 
334
- @pre(lambda error_str: isinstance(error_str, str))
337
+ @pre(lambda error_str: len(error_str) > 0)
335
338
  @post(lambda result: result is None or isinstance(result, int))
336
339
  def _extract_hypothesis_seed(error_str: str) -> int | None:
337
340
  """Extract Hypothesis seed from error message (DX-26).
@@ -354,7 +357,7 @@ def _extract_hypothesis_seed(error_str: str) -> int | None:
354
357
  return None
355
358
 
356
359
 
357
- @pre(lambda name, reason: isinstance(name, str) and isinstance(reason, str))
360
+ @pre(lambda name, reason: len(name) > 0 and len(reason) > 0)
358
361
  @post(lambda result: isinstance(result, PropertyTestResult) and result.passed)
359
362
  def _skip_result(name: str, reason: str) -> PropertyTestResult:
360
363
  """Create a skip result (passed=True, 0 examples)."""
@@ -368,7 +371,7 @@ _SKIP_PATTERNS = (
368
371
  )
369
372
 
370
373
 
371
- @pre(lambda err_str, func_name, max_examples: isinstance(err_str, str))
374
+ @pre(lambda err_str, func_name, max_examples: len(err_str) > 0 and len(func_name) > 0 and max_examples > 0)
372
375
  @post(lambda result: isinstance(result, PropertyTestResult))
373
376
  def _handle_test_exception(
374
377
  err_str: str, func_name: str, max_examples: int
invar/core/purity.py CHANGED
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import ast
15
15
 
16
- from deal import pre
16
+ from deal import post, pre
17
17
 
18
18
  from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
19
19
 
@@ -284,7 +284,7 @@ def count_doctest_lines(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
284
284
  # Rule checking functions
285
285
 
286
286
 
287
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
287
+ @post(lambda result: all(v.rule == "internal_import" for v in result)) # Rule consistency
288
288
  def check_internal_imports(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
289
289
  """
290
290
  Check for imports inside function bodies.
@@ -324,7 +324,7 @@ def check_internal_imports(file_info: FileInfo, config: RuleConfig) -> list[Viol
324
324
  return violations
325
325
 
326
326
 
327
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
327
+ @post(lambda result: all(v.rule == "impure_call" for v in result)) # Rule consistency
328
328
  def check_impure_calls(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
329
329
  """
330
330
  Check for calls to known impure functions.
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import re
11
11
  from dataclasses import dataclass
12
12
 
13
- from deal import post, pre
13
+ from deal import post
14
14
 
15
15
  # Name patterns suggesting impurity
16
16
  IMPURE_NAME_PATTERNS = [
@@ -75,8 +75,7 @@ class HeuristicResult:
75
75
  hints: list[str]
76
76
 
77
77
 
78
- @pre(lambda func_name, hints: isinstance(func_name, str) and isinstance(hints, list))
79
- @post(lambda result: isinstance(result, tuple) and len(result) == 2)
78
+ @post(lambda result: len(result) == 2 and all(x >= 0 for x in result)) # Scores are non-negative
80
79
  def _analyze_name_patterns(func_name: str, hints: list[str]) -> tuple[int, int]:
81
80
  """Analyze function name for purity hints. Returns (impure_score, pure_score).
82
81
 
@@ -98,8 +97,7 @@ def _analyze_name_patterns(func_name: str, hints: list[str]) -> tuple[int, int]:
98
97
  return impure, pure
99
98
 
100
99
 
101
- @pre(lambda signature, hints: isinstance(hints, list))
102
- @post(lambda result: isinstance(result, tuple) and len(result) == 2)
100
+ @post(lambda result: len(result) == 2 and all(x >= 0 for x in result)) # Scores are non-negative
103
101
  def _analyze_signature(signature: str | None, hints: list[str]) -> tuple[int, int]:
104
102
  """Analyze signature for purity hints. Returns (impure_score, pure_score).
105
103
 
@@ -122,8 +120,7 @@ def _analyze_signature(signature: str | None, hints: list[str]) -> tuple[int, in
122
120
  return impure, pure
123
121
 
124
122
 
125
- @pre(lambda docstring, hints: isinstance(hints, list))
126
- @post(lambda result: isinstance(result, int) and result >= 0)
123
+ @post(lambda result: result >= 0) # Impure score is non-negative
127
124
  def _analyze_docstring(docstring: str | None, hints: list[str]) -> int:
128
125
  """Analyze docstring for purity hints. Returns impure_score.
129
126
 
@@ -141,8 +138,7 @@ def _analyze_docstring(docstring: str | None, hints: list[str]) -> int:
141
138
  return 0
142
139
 
143
140
 
144
- @pre(lambda func_name, signature=None, docstring=None: isinstance(func_name, str))
145
- @post(lambda result: isinstance(result, HeuristicResult))
141
+ @post(lambda result: 0.0 <= result.confidence <= 1.0) # Confidence in [0, 1]
146
142
  def analyze_purity_heuristic(
147
143
  func_name: str,
148
144
  signature: str | None = None,
invar/core/references.py CHANGED
@@ -17,8 +17,8 @@ from deal import post, pre
17
17
  from invar.core.models import FileInfo, PerceptionMap, SymbolKind, SymbolRefs
18
18
 
19
19
 
20
- @pre(lambda source, known_symbols: isinstance(source, str) and len(source) > 0)
21
- @post(lambda result: isinstance(result, list))
20
+ @pre(lambda source, known_symbols: len(source) > 0 and len(known_symbols) > 0) # Non-empty inputs
21
+ @post(lambda result: all(isinstance(name, str) and line > 0 for name, line in result)) # Valid refs
22
22
  def find_references_in_source(source: str, known_symbols: set[str]) -> list[tuple[str, int]]:
23
23
  """
24
24
  Find references to known symbols in source code.
@@ -51,8 +51,7 @@ def find_references_in_source(source: str, known_symbols: set[str]) -> list[tupl
51
51
  return list(seen)
52
52
 
53
53
 
54
- @pre(lambda file_infos: isinstance(file_infos, list))
55
- @post(lambda result: isinstance(result, dict))
54
+ @post(lambda result: all(isinstance(v, str) for v in result.values()))
56
55
  def build_symbol_table(file_infos: list[FileInfo]) -> dict[str, str]:
57
56
  """
58
57
  Build a mapping of symbol names to their defining file.
@@ -78,7 +77,7 @@ def build_symbol_table(file_infos: list[FileInfo]) -> dict[str, str]:
78
77
  return symbol_table
79
78
 
80
79
 
81
- @pre(lambda file_infos, sources: isinstance(file_infos, list))
80
+ @post(lambda result: all("::" in k and v >= 0 for k, v in result.items())) # Valid ref counts
82
81
  def count_cross_file_references(
83
82
  file_infos: list[FileInfo], sources: dict[str, str]
84
83
  ) -> dict[str, int]:
@@ -124,6 +123,8 @@ def count_cross_file_references(
124
123
 
125
124
  @pre(lambda file_infos, sources, project_root: (
126
125
  isinstance(file_infos, list) and
126
+ all(isinstance(fi, FileInfo) for fi in file_infos) and
127
+ isinstance(sources, dict) and
127
128
  isinstance(project_root, str) and len(project_root) > 0
128
129
  ))
129
130
  def build_perception_map(
@@ -172,8 +173,9 @@ def build_perception_map(
172
173
  )
173
174
  except Exception:
174
175
  # Handle CrossHair symbolic value validation failures
176
+ # Use safe literal value that always passes validation
175
177
  return PerceptionMap(
176
- project_root="",
178
+ project_root="/",
177
179
  total_files=0,
178
180
  total_symbols=0,
179
181
  symbols=[],
@@ -38,8 +38,7 @@ SECURITY_WORD_PATTERNS: tuple[str, ...] = (
38
38
  )
39
39
 
40
40
 
41
- @pre(lambda file_info: isinstance(file_info, FileInfo))
42
- @post(lambda result: isinstance(result, tuple) and len(result) == 3)
41
+ @post(lambda result: len(result) == 3 and 0.0 <= result[0] <= 1.0) # Ratio in [0, 1]
43
42
  def calculate_contract_ratio(file_info: FileInfo) -> tuple[float, int, int]:
44
43
  """
45
44
  Calculate contract coverage ratio for a file (DX-31).
@@ -88,7 +87,7 @@ def calculate_contract_ratio(file_info: FileInfo) -> tuple[float, int, int]:
88
87
  return (ratio, total, with_contracts)
89
88
 
90
89
 
91
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
90
+ @post(lambda result: all(v.rule == "contract_quality_ratio" for v in result))
92
91
  def check_contract_quality_ratio(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
93
92
  """
94
93
  Check contract coverage ratio in Core files (DX-30).
@@ -141,8 +140,7 @@ def check_contract_quality_ratio(file_info: FileInfo, config: RuleConfig) -> lis
141
140
  return violations
142
141
 
143
142
 
144
- @pre(lambda path: isinstance(path, str))
145
- @post(lambda result: isinstance(result, bool))
143
+ # @invar:allow missing_contract: Boolean predicate, accepts empty string (doctest shows)
146
144
  def is_security_sensitive(path: str) -> bool:
147
145
  """
148
146
  Check if path indicates security-sensitive code (DX-31).
@@ -200,7 +198,7 @@ def is_security_sensitive(path: str) -> bool:
200
198
  return any(word in SECURITY_WORD_PATTERNS for word in words)
201
199
 
202
200
 
203
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
201
+ @post(lambda result: all(v.rule == "review_suggested" for v in result))
204
202
  def check_review_suggested(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
205
203
  """
206
204
  Suggest independent review when conditions warrant (DX-31).
@@ -296,3 +294,77 @@ def check_review_suggested(file_info: FileInfo, config: RuleConfig) -> list[Viol
296
294
  )
297
295
 
298
296
  return violations
297
+
298
+
299
+ @pre(lambda escapes: all(len(e) == 3 for e in escapes)) # Validate tuple structure
300
+ @post(lambda result: all(v.rule == "duplicate_escape_reason" for v in result))
301
+ def check_duplicate_escape_reasons(
302
+ escapes: list[tuple[str, str, str]],
303
+ ) -> list[Violation]:
304
+ """
305
+ Detect duplicate escape hatch reasons across files (DX-33 Option E).
306
+
307
+ Warns when 3+ files share identical escape reason text,
308
+ suggesting a systematic issue that should be fixed at the root.
309
+
310
+ Args:
311
+ escapes: List of (file_path, rule, reason) tuples
312
+
313
+ Returns:
314
+ List of violations for duplicate reasons
315
+
316
+ Examples:
317
+ >>> check_duplicate_escape_reasons([])
318
+ []
319
+ >>> # 2 files with same reason - no warning (threshold is 3)
320
+ >>> escapes = [
321
+ ... ("a.py", "rule", "same reason"),
322
+ ... ("b.py", "rule", "same reason"),
323
+ ... ]
324
+ >>> check_duplicate_escape_reasons(escapes)
325
+ []
326
+ >>> # 3+ files with same reason - warning
327
+ >>> escapes = [
328
+ ... ("a.py", "rule", "False positive - .get()"),
329
+ ... ("b.py", "rule", "False positive - .get()"),
330
+ ... ("c.py", "rule", "False positive - .get()"),
331
+ ... ]
332
+ >>> vs = check_duplicate_escape_reasons(escapes)
333
+ >>> len(vs) == 1
334
+ True
335
+ >>> "3 files" in vs[0].message
336
+ True
337
+ >>> "False positive" in vs[0].message
338
+ True
339
+ """
340
+ violations: list[Violation] = []
341
+
342
+ # Group by (reason) - normalize whitespace for comparison
343
+ reason_files: dict[str, list[str]] = {}
344
+ for file_path, _rule, reason in escapes:
345
+ normalized = reason.strip().lower()
346
+ if normalized not in reason_files:
347
+ reason_files[normalized] = []
348
+ reason_files[normalized].append(file_path)
349
+
350
+ # Check for duplicates (threshold: 3+ files)
351
+ for reason, files in reason_files.items():
352
+ if len(files) >= 3:
353
+ # Get original reason text from first occurrence
354
+ original_reason = next(
355
+ r for f, _, r in escapes if r.strip().lower() == reason
356
+ )
357
+ violations.append(
358
+ Violation(
359
+ rule="duplicate_escape_reason",
360
+ severity=Severity.WARNING,
361
+ file="<project>",
362
+ line=None,
363
+ message=f'{len(files)} files share escape reason: "{original_reason}"',
364
+ suggestion="Consider fixing the detection rule instead of adding escapes. "
365
+ f"Files: {', '.join(sorted(set(files))[:5])}"
366
+ + (f" (+{len(files) - 5} more)" if len(files) > 5 else ""),
367
+ )
368
+ )
369
+
370
+ return violations
invar/core/rule_meta.py CHANGED
@@ -106,6 +106,14 @@ RULE_META: dict[str, RuleMeta] = {
106
106
  cannot_detect=("Runtime binding errors",),
107
107
  hint="Lambda must accept ALL function parameters (include defaults like x=10)",
108
108
  ),
109
+ "postcondition_scope_error": RuleMeta(
110
+ name="postcondition_scope_error",
111
+ severity=Severity.ERROR,
112
+ category=RuleCategory.CONTRACTS,
113
+ detects="@post lambda references function parameters (not available in postcondition)",
114
+ cannot_detect=("Indirect parameter access via closures",),
115
+ hint="@post can only use 'result', not function parameters like x, y",
116
+ ),
109
117
  "must_use_ignored": RuleMeta(
110
118
  name="must_use_ignored",
111
119
  severity=Severity.WARNING,
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,21 +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, # DX-28
15
+ check_skip_without_reason,
16
16
  )
17
17
  from invar.core.entry_points import get_symbol_lines, has_allow_marker, is_entry_point
18
18
  from invar.core.extraction import format_extraction_hint
19
19
  from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
20
20
  from invar.core.must_use import check_must_use
21
+ from invar.core.postcondition_scope import check_postcondition_scope
21
22
  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
- )
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
30
25
  from invar.core.suggestions import format_suggestion_for_violation
31
26
  from invar.core.utils import get_excluded_rules
32
27
 
@@ -58,7 +53,6 @@ def _build_size_suggestion(base: str, extraction_hint: str, func_hint: str) -> s
58
53
  return f"{base}{func_hint}" if func_hint else base
59
54
 
60
55
 
61
- @pre(lambda file_info: isinstance(file_info, FileInfo))
62
56
  @post(lambda result: isinstance(result, str))
63
57
  def _get_func_hint(file_info: FileInfo) -> str:
64
58
  """Get top 5 largest functions as hint string."""
@@ -69,7 +63,7 @@ def _get_func_hint(file_info: FileInfo) -> str:
69
63
  return f" Functions: {', '.join(f'{n}({sz}L)' for n, sz in funcs)}" if funcs else ""
70
64
 
71
65
 
72
- @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))
73
67
  def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
74
68
  """
75
69
  Check if file exceeds maximum line count or warning threshold.
@@ -110,7 +104,7 @@ def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
110
104
  return violations
111
105
 
112
106
 
113
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
107
+ @post(lambda result: all(v.rule == "function_size" for v in result))
114
108
  def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
115
109
  """
116
110
  Check if any function exceeds maximum line count.
@@ -158,7 +152,7 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
158
152
  return violations
159
153
 
160
154
 
161
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
155
+ @post(lambda result: all(v.rule == "forbidden_import" for v in result))
162
156
  def check_forbidden_imports(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
163
157
  """
164
158
  Check for forbidden imports in Core files.
@@ -202,7 +196,7 @@ def check_forbidden_imports(file_info: FileInfo, config: RuleConfig) -> list[Vio
202
196
  return violations
203
197
 
204
198
 
205
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
199
+ @post(lambda result: all(v.rule == "missing_contract" for v in result))
206
200
  def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
207
201
  """
208
202
  Check that public Core functions have contracts.
@@ -223,9 +217,13 @@ def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
223
217
  if not file_info.is_core or not config.require_contracts:
224
218
  return violations
225
219
 
220
+ source = file_info.source or ""
226
221
  for symbol in file_info.symbols:
227
222
  # Check all functions and methods - agent needs contracts everywhere
228
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
229
227
  kind_name = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
230
228
  suggestion = format_suggestion_for_violation(symbol, "missing_contract")
231
229
  violations.append(
@@ -242,7 +240,7 @@ def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
242
240
  return violations
243
241
 
244
242
 
245
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
243
+ @post(lambda result: all(v.rule == "missing_doctest" for v in result))
246
244
  def check_doctests(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
247
245
  """
248
246
  Check that contracted functions have doctest examples.
@@ -292,7 +290,7 @@ def check_doctests(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
292
290
  return violations
293
291
 
294
292
 
295
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
293
+ @post(lambda result: all(v.rule == "shell_result" for v in result))
296
294
  def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
297
295
  """
298
296
  Check that Shell functions with return values use Result[T, E].
@@ -344,7 +342,7 @@ def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violatio
344
342
  return violations
345
343
 
346
344
 
347
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
345
+ @post(lambda result: all(v.rule == "entry_point_too_thick" for v in result))
348
346
  def check_entry_point_thin(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
349
347
  """
350
348
  Check that entry points are thin (DX-23).
@@ -415,6 +413,7 @@ def get_all_rules() -> list[RuleFunc]:
415
413
  check_redundant_type_contracts,
416
414
  check_param_mismatch,
417
415
  check_partial_contract,
416
+ check_postcondition_scope,
418
417
  check_must_use,
419
418
  check_skip_without_reason, # DX-28
420
419
  check_contract_quality_ratio, # DX-30
@@ -462,7 +461,7 @@ def _apply_severity_override(v: Violation, overrides: dict[str, str]) -> Violati
462
461
  )
463
462
 
464
463
 
465
- @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)
466
465
  def check_all_rules(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
467
466
  """
468
467
  Run all rules against a file and collect violations.
@@ -93,8 +93,7 @@ COMPLEXITY_MARKER_PATTERN = re.compile(r"#\s*@shell_complexity\s*:")
93
93
  ORCHESTRATION_MARKER_PATTERN = re.compile(r"#\s*@shell_orchestration\s*:")
94
94
 
95
95
 
96
- @pre(lambda source: isinstance(source, str))
97
- @post(lambda result: isinstance(result, bool))
96
+ # @invar:allow missing_contract: Boolean predicate, empty string is valid input
98
97
  def has_io_operations(source: str) -> bool:
99
98
  """
100
99
  Check if source code contains I/O operations.
@@ -112,8 +111,7 @@ def has_io_operations(source: str) -> bool:
112
111
  return any(indicator in source for indicator in IO_INDICATORS)
113
112
 
114
113
 
115
- @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
116
- @post(lambda result: isinstance(result, bool))
114
+ @pre(lambda symbol, source: symbol is not None) # Symbol must exist
117
115
  def has_orchestration_marker(symbol: Symbol, source: str) -> bool:
118
116
  """
119
117
  Check if symbol has @shell_orchestration marker comment.
@@ -146,8 +144,7 @@ def has_orchestration_marker(symbol: Symbol, source: str) -> bool:
146
144
  return bool(ORCHESTRATION_MARKER_PATTERN.search(context))
147
145
 
148
146
 
149
- @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
150
- @post(lambda result: isinstance(result, bool))
147
+ @pre(lambda symbol, source: symbol is not None) # Symbol must exist
151
148
  def has_complexity_marker(symbol: Symbol, source: str) -> bool:
152
149
  """
153
150
  Check if symbol has @shell_complexity marker comment.
@@ -181,8 +178,7 @@ def has_complexity_marker(symbol: Symbol, source: str) -> bool:
181
178
  return bool(COMPLEXITY_MARKER_PATTERN.search(context))
182
179
 
183
180
 
184
- @pre(lambda source: isinstance(source, str))
185
- @post(lambda result: isinstance(result, int) and result >= 0)
181
+ @post(lambda result: result >= 0) # Branch count is non-negative
186
182
  def count_branches(source: str) -> int:
187
183
  """
188
184
  Count the number of branches in source code.
@@ -225,8 +221,7 @@ def count_branches(source: str) -> int:
225
221
  return count
226
222
 
227
223
 
228
- @pre(lambda symbol, file_source: symbol is not None and isinstance(file_source, str))
229
- @post(lambda result: isinstance(result, str))
224
+ @pre(lambda symbol, file_source: symbol is not None) # Symbol must exist
230
225
  def get_symbol_source(symbol: Symbol, file_source: str) -> str:
231
226
  """
232
227
  Extract the source code for a specific symbol.
@@ -24,7 +24,7 @@ from invar.core.shell_analysis import (
24
24
  )
25
25
 
26
26
 
27
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
27
+ @post(lambda result: all(v.rule == "shell_pure_logic" for v in result))
28
28
  def check_shell_pure_logic(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
29
29
  """
30
30
  Check that Shell functions contain I/O operations (DX-22).
@@ -79,7 +79,7 @@ def check_shell_pure_logic(file_info: FileInfo, config: RuleConfig) -> list[Viol
79
79
  return violations
80
80
 
81
81
 
82
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
82
+ @post(lambda result: all(v.rule == "shell_too_complex" for v in result))
83
83
  def check_shell_too_complex(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
84
84
  """
85
85
  Check that Shell functions don't have excessive branching (DX-22).