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,197 @@
1
+ """
2
+ Hypothesis strategies for format-driven property testing.
3
+
4
+ DX-28: Strategies that generate realistic test data matching
5
+ production formats, enabling property tests to catch semantic bugs.
6
+
7
+ These strategies are optional - they require Hypothesis to be installed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from deal import post, pre
15
+ from invar_runtime import skip_property_test
16
+
17
+ if TYPE_CHECKING:
18
+ from hypothesis.strategies import SearchStrategy
19
+
20
+ try:
21
+ from hypothesis import strategies as st
22
+
23
+ HYPOTHESIS_AVAILABLE = True
24
+ except ImportError:
25
+ HYPOTHESIS_AVAILABLE = False
26
+
27
+
28
+ @skip_property_test("no_params: Zero-parameter function, property testing cannot vary inputs")
29
+ @post(lambda result: result is not None)
30
+ def crosshair_line() -> SearchStrategy[str]:
31
+ """
32
+ Generate a CrossHair counterexample line.
33
+
34
+ Format: `file.py:line: error: ErrorType when calling func(args)`
35
+
36
+ Examples:
37
+ >>> if HYPOTHESIS_AVAILABLE:
38
+ ... line = crosshair_line().example()
39
+ ... assert ": error:" in line.lower()
40
+ """
41
+ if not HYPOTHESIS_AVAILABLE:
42
+ raise ImportError("Hypothesis required: pip install hypothesis")
43
+
44
+ filenames = st.from_regex(r"[a-z_]{1,20}\.py", fullmatch=True)
45
+ line_nums = st.integers(min_value=1, max_value=1000)
46
+ error_types = st.sampled_from(
47
+ [
48
+ "IndexError",
49
+ "KeyError",
50
+ "ValueError",
51
+ "TypeError",
52
+ "AssertionError",
53
+ "ZeroDivisionError",
54
+ ]
55
+ )
56
+ func_names = st.from_regex(r"[a-z_]{1,15}", fullmatch=True)
57
+ args = st.from_regex(r"[a-z]=-?\d{1,5}", fullmatch=True)
58
+
59
+ return st.builds(
60
+ lambda f, ln, e, fn, a: f"{f}:{ln}: error: {e} when calling {fn}({a})",
61
+ filenames,
62
+ line_nums,
63
+ error_types,
64
+ func_names,
65
+ args,
66
+ )
67
+
68
+
69
+ @pre(lambda min_errors, max_errors: min_errors >= 0 and max_errors >= min_errors)
70
+ @post(lambda result: result is not None)
71
+ def crosshair_output(
72
+ min_errors: int = 0,
73
+ max_errors: int = 5,
74
+ ) -> SearchStrategy[str]:
75
+ """
76
+ Generate complete CrossHair output with multiple lines.
77
+
78
+ Args:
79
+ min_errors: Minimum number of error lines
80
+ max_errors: Maximum number of error lines
81
+
82
+ Examples:
83
+ >>> if HYPOTHESIS_AVAILABLE:
84
+ ... output = crosshair_output(min_errors=1, max_errors=3).example()
85
+ ... assert ": error:" in output.lower()
86
+ """
87
+ if not HYPOTHESIS_AVAILABLE:
88
+ raise ImportError("Hypothesis required: pip install hypothesis")
89
+
90
+ headers = st.sampled_from(
91
+ [
92
+ "Checking module...",
93
+ "Running crosshair check...",
94
+ "",
95
+ ]
96
+ )
97
+
98
+ error_lines = st.lists(crosshair_line(), min_size=min_errors, max_size=max_errors)
99
+
100
+ footers = st.sampled_from(
101
+ [
102
+ "",
103
+ "Done.",
104
+ ]
105
+ )
106
+
107
+ return st.builds(
108
+ lambda h, errors, f: "\n".join([h, *errors, f]),
109
+ headers,
110
+ error_lines,
111
+ footers,
112
+ )
113
+
114
+
115
+ @pre(lambda pattern, min_occurrences, max_occurrences: len(pattern) > 0 and min_occurrences >= 0)
116
+ @post(lambda result: result is not None)
117
+ def text_with_pattern(
118
+ pattern: str,
119
+ min_occurrences: int = 1,
120
+ max_occurrences: int = 5,
121
+ ) -> SearchStrategy[str]:
122
+ """
123
+ Generate text that contains a specific pattern.
124
+
125
+ Useful for testing extraction functions that should find
126
+ occurrences of a pattern in text.
127
+
128
+ Args:
129
+ pattern: The pattern that must appear in the output
130
+ min_occurrences: Minimum times pattern appears
131
+ max_occurrences: Maximum times pattern appears
132
+
133
+ Examples:
134
+ >>> if HYPOTHESIS_AVAILABLE:
135
+ ... text = text_with_pattern(": error:", 2, 5).example()
136
+ ... assert text.count(": error:") >= 2
137
+ """
138
+ if not HYPOTHESIS_AVAILABLE:
139
+ raise ImportError("Hypothesis required: pip install hypothesis")
140
+
141
+ # Generate lines that contain the pattern
142
+ prefix = st.from_regex(r"[a-z0-9_.]{1,30}", fullmatch=True)
143
+ suffix = st.from_regex(r"[a-zA-Z0-9 ]{1,50}", fullmatch=True)
144
+
145
+ pattern_line = st.builds(
146
+ lambda p, s: f"{p}{pattern}{s}",
147
+ prefix,
148
+ suffix,
149
+ )
150
+
151
+ # Generate noise lines without the pattern
152
+ noise_line = st.from_regex(r"[a-zA-Z0-9 ]{1,80}", fullmatch=True).filter(
153
+ lambda x: pattern not in x
154
+ )
155
+
156
+ # Combine into full output
157
+ pattern_lines = st.lists(
158
+ pattern_line, min_size=min_occurrences, max_size=max_occurrences
159
+ )
160
+ noise_lines = st.lists(noise_line, min_size=0, max_size=10)
161
+
162
+ return st.builds(
163
+ lambda p, n: "\n".join(p + n),
164
+ pattern_lines,
165
+ noise_lines,
166
+ ).map(lambda x: "\n".join(sorted(x.split("\n"), key=lambda _: __import__("random").random())))
167
+
168
+
169
+ @pre(lambda pattern: len(pattern) > 0)
170
+ @post(lambda result: result is not None)
171
+ def extraction_test_case(
172
+ pattern: str,
173
+ ) -> SearchStrategy[tuple[str, int]]:
174
+ """
175
+ Generate (text, expected_count) pairs for testing extraction functions.
176
+
177
+ The expected_count is the number of lines that should be extracted.
178
+
179
+ Examples:
180
+ >>> if HYPOTHESIS_AVAILABLE:
181
+ ... text, count = extraction_test_case(": error:").example()
182
+ ... actual = len([l for l in text.split("\\n") if ": error:" in l])
183
+ ... assert actual == count
184
+ """
185
+ if not HYPOTHESIS_AVAILABLE:
186
+ raise ImportError("Hypothesis required: pip install hypothesis")
187
+
188
+ count = st.integers(min_value=0, max_value=10)
189
+
190
+ return count.flatmap(
191
+ lambda c: st.tuples(
192
+ text_with_pattern(pattern, min_occurrences=c, max_occurrences=c)
193
+ if c > 0
194
+ else st.just("no matches here"),
195
+ st.just(c),
196
+ )
197
+ )
invar/core/formatter.py CHANGED
@@ -200,12 +200,18 @@ def format_signatures_json(symbols: list[Symbol], file_path: str) -> dict:
200
200
  # Phase 8.2: Agent-mode formatting
201
201
 
202
202
 
203
- @pre(lambda report: isinstance(report, GuardReport))
204
- def format_guard_agent(report: GuardReport) -> dict:
203
+ @pre(lambda report, combined_status=None: isinstance(report, GuardReport))
204
+ def format_guard_agent(report: GuardReport, combined_status: str | None = None) -> dict:
205
205
  """
206
- Format Guard report for Agent consumption (Phase 8.2).
206
+ Format Guard report for Agent consumption (Phase 8.2 + DX-26).
207
207
 
208
208
  Provides structured output with actionable fix instructions.
209
+ DX-26: status now reflects ALL test phases when combined_status is provided.
210
+
211
+ Args:
212
+ report: Guard analysis report
213
+ combined_status: True guard status including all test phases (DX-26).
214
+ If None, uses report.passed (static-only, deprecated).
209
215
 
210
216
  Examples:
211
217
  >>> from invar.core.models import GuardReport, Violation, Severity
@@ -219,9 +225,26 @@ def format_guard_agent(report: GuardReport) -> dict:
219
225
  'passed'
220
226
  >>> len(d["fixes"])
221
227
  1
228
+ >>> # DX-26: combined_status overrides report.passed
229
+ >>> d2 = format_guard_agent(report, combined_status="failed")
230
+ >>> d2["status"]
231
+ 'failed'
232
+ >>> d2["static"]["passed"] # Static still shows passed
233
+ True
222
234
  """
235
+ # DX-26: Use combined status if provided, else fall back to static-only
236
+ status = combined_status if combined_status else ("passed" if report.passed else "failed")
237
+ static_passed = report.errors == 0
238
+
223
239
  return {
224
- "status": "passed" if report.passed else "failed",
240
+ "status": status,
241
+ # DX-26: Separate static results from combined status
242
+ "static": {
243
+ "passed": static_passed,
244
+ "errors": report.errors,
245
+ "warnings": report.warnings,
246
+ "infos": report.infos,
247
+ },
225
248
  "summary": {
226
249
  "files_checked": report.files_checked,
227
250
  "errors": report.errors,
@@ -402,6 +402,51 @@ def _get_user_strategies(func: Callable) -> dict[str, StrategySpec]:
402
402
  return user_specs
403
403
 
404
404
 
405
+ @pre(lambda source: isinstance(source, str))
406
+ @post(lambda result: isinstance(result, list))
407
+ def _extract_pre_lambdas_from_source(source: str) -> list[str]:
408
+ """
409
+ Extract lambda expressions from @pre decorators with balanced parenthesis.
410
+
411
+ >>> _extract_pre_lambdas_from_source("@pre(lambda x: x > 0)")
412
+ ['lambda x: x > 0']
413
+ >>> _extract_pre_lambdas_from_source("@pre(lambda x: len(x) > 0)")
414
+ ['lambda x: len(x) > 0']
415
+ >>> _extract_pre_lambdas_from_source("@pre(lambda x, y: isinstance(x, str))")
416
+ ['lambda x, y: isinstance(x, str)']
417
+ >>> _extract_pre_lambdas_from_source("")
418
+ []
419
+ """
420
+ results = []
421
+ i = 0
422
+ while i < len(source):
423
+ # Find @pre(
424
+ pre_match = re.search(r"@pre\s*\(", source[i:])
425
+ if not pre_match:
426
+ break
427
+ start = i + pre_match.end()
428
+
429
+ # Find matching closing paren with balance counting
430
+ paren_depth = 1
431
+ j = start
432
+ while j < len(source) and paren_depth > 0:
433
+ if source[j] == "(":
434
+ paren_depth += 1
435
+ elif source[j] == ")":
436
+ paren_depth -= 1
437
+ j += 1
438
+
439
+ if paren_depth == 0:
440
+ # Extract content between @pre( and matching )
441
+ content = source[start : j - 1].strip()
442
+ if content.startswith("lambda"):
443
+ results.append(content)
444
+
445
+ i = j
446
+
447
+ return results
448
+
449
+
405
450
  @pre(lambda func: callable(func))
406
451
  @post(lambda result: isinstance(result, list))
407
452
  def _extract_pre_sources(func: Callable) -> list[str]:
@@ -413,13 +458,10 @@ def _extract_pre_sources(func: Callable) -> list[str]:
413
458
  # deal stores contracts in _deal attribute
414
459
  pass
415
460
 
416
- # Try to extract from source
461
+ # Try to extract from source using balanced parenthesis matching
417
462
  try:
418
463
  source = inspect.getsource(func)
419
- # Find @pre decorators
420
- pre_pattern = r"@pre\s*\(\s*(lambda[^)]+)\s*\)"
421
- matches = re.findall(pre_pattern, source)
422
- pre_sources.extend(matches)
464
+ pre_sources.extend(_extract_pre_lambdas_from_source(source))
423
465
  except (OSError, TypeError):
424
466
  pass
425
467
 
@@ -8,6 +8,7 @@ import re
8
8
  from deal import post, pre
9
9
 
10
10
 
11
+ @pre(lambda tree: isinstance(tree, ast.AST))
11
12
  @post(lambda result: result is None or isinstance(result, ast.Lambda))
12
13
  def find_lambda(tree: ast.Expression) -> ast.Lambda | None:
13
14
  """Find the lambda node in an expression tree.
invar/core/models.py CHANGED
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  from enum import Enum
11
11
  from typing import Literal
12
12
 
13
- from deal import pre
13
+ from deal import post, pre
14
14
  from pydantic import BaseModel, Field
15
15
 
16
16
 
@@ -133,7 +133,7 @@ class GuardReport(BaseModel):
133
133
  self.core_functions_with_contracts += with_contracts
134
134
 
135
135
  @property
136
- @pre(lambda self: isinstance(self, GuardReport))
136
+ @post(lambda result: 0 <= result <= 100)
137
137
  def contract_coverage_pct(self) -> int:
138
138
  """
139
139
  Get contract coverage percentage (P24).
@@ -150,7 +150,7 @@ class GuardReport(BaseModel):
150
150
  return int(self.core_functions_with_contracts / self.core_functions_total * 100)
151
151
 
152
152
  @property
153
- @pre(lambda self: isinstance(self, GuardReport))
153
+ @post(lambda result: all(k in result for k in ("tautology", "empty", "partial", "type_only")))
154
154
  def contract_issue_counts(self) -> dict[str, int]:
155
155
  """
156
156
  Count contract quality issues by type (P24).
@@ -178,7 +178,7 @@ class GuardReport(BaseModel):
178
178
  return counts
179
179
 
180
180
  @property
181
- @pre(lambda self: isinstance(self, GuardReport))
181
+ @post(lambda result: isinstance(result, bool))
182
182
  def passed(self) -> bool:
183
183
  """
184
184
  Check if guard passed (no errors).
@@ -218,10 +218,18 @@ class RuleConfig(BaseModel):
218
218
  500
219
219
  >>> config.strict_pure # Phase 9 P12: Default ON for agents
220
220
  True
221
+ >>> # MINOR-6: Value ranges validated
222
+ >>> RuleConfig(max_file_lines=0) # doctest: +IGNORE_EXCEPTION_DETAIL
223
+ Traceback (most recent call last):
224
+ pydantic_core._pydantic_core.ValidationError: ...
221
225
  """
222
226
 
223
- max_file_lines: int = 500 # Phase 9 P1: Raised from 300 for less friction
224
- max_function_lines: int = 50
227
+ # MINOR-6: Added ge=1 constraints for numeric fields
228
+ max_file_lines: int = Field(default=500, ge=1) # Phase 9 P1: Raised from 300
229
+ max_function_lines: int = Field(default=50, ge=1)
230
+ entry_max_lines: int = Field(default=15, ge=1) # DX-23: Entry point max lines
231
+ shell_max_branches: int = Field(default=3, ge=1) # DX-22: Shell function max branches
232
+ shell_complexity_debt_limit: int = Field(default=5, ge=0) # DX-22: 0 = no limit
225
233
  forbidden_imports: tuple[str, ...] = (
226
234
  "os",
227
235
  "sys",
@@ -236,18 +244,15 @@ class RuleConfig(BaseModel):
236
244
  require_contracts: bool = True
237
245
  require_doctests: bool = True
238
246
  strict_pure: bool = True # Phase 9 P12: Default ON for agent-native
239
- use_code_lines: bool = False
240
- exclude_doctest_lines: bool = False
247
+ # DX-22: Removed use_code_lines and exclude_doctest_lines
248
+ # (merged into default behavior - always exclude doctest lines from size calc)
241
249
  # Phase 9 P1: Rule exclusions for specific file patterns
242
250
  rule_exclusions: list[RuleExclusion] = Field(default_factory=list)
243
251
  # Phase 9 P2: Per-rule severity overrides (off, info, warning, error)
244
- severity_overrides: dict[str, str] = Field(
245
- default_factory=lambda: {
246
- "redundant_type_contract": "off", # Expected behavior when forcing contracts
247
- }
248
- )
252
+ # DX-22: Simplified defaults - most rules have correct severity now
253
+ severity_overrides: dict[str, str] = Field(default_factory=dict)
249
254
  # Phase 9 P8: File size warning threshold (0 to disable, 0.8 = warn at 80%)
250
- size_warning_threshold: float = 0.8
255
+ size_warning_threshold: float = Field(default=0.8, ge=0.0, le=1.0)
251
256
  # B4: User-declared purity (override heuristics)
252
257
  purity_pure: list[str] = Field(default_factory=list) # Known pure functions
253
258
  purity_impure: list[str] = Field(default_factory=list) # Known impure functions
@@ -283,7 +288,8 @@ class PerceptionMap(BaseModel):
283
288
  5
284
289
  """
285
290
 
286
- project_root: str
287
- total_files: int
288
- total_symbols: int
291
+ # MINOR-7: Added field validators
292
+ project_root: str = Field(min_length=1)
293
+ total_files: int = Field(ge=0)
294
+ total_symbols: int = Field(ge=0)
289
295
  symbols: list[SymbolRefs] = Field(default_factory=list)
invar/core/parser.py CHANGED
@@ -21,13 +21,13 @@ from invar.core.purity import (
21
21
  )
22
22
 
23
23
 
24
- @pre(lambda source, path="<string>": isinstance(source, str) and len(source) > 0)
24
+ @pre(lambda source, path="<string>": isinstance(source, str) and len(source.strip()) > 0)
25
25
  def parse_source(source: str, path: str = "<string>") -> FileInfo | None:
26
26
  """
27
27
  Parse Python source code and extract symbols.
28
28
 
29
29
  Args:
30
- source: Python source code as string
30
+ source: Python source code as string (must contain non-whitespace)
31
31
  path: Path for reporting (not used for I/O)
32
32
 
33
33
  Returns:
@@ -41,6 +41,10 @@ def parse_source(source: str, path: str = "<string>") -> FileInfo | None:
41
41
  1
42
42
  >>> info.symbols[0].name
43
43
  'foo'
44
+ >>> parse_source(" \\n\\t ") # Whitespace-only returns None via contract
45
+ Traceback (most recent call last):
46
+ ...
47
+ deal.PreContractError: ...
44
48
  """
45
49
  try:
46
50
  tree = ast.parse(source)
@@ -19,13 +19,18 @@ if TYPE_CHECKING:
19
19
 
20
20
  @dataclass
21
21
  class PropertyTestResult:
22
- """Result of running a property test."""
22
+ """Result of running a property test.
23
+
24
+ DX-26: Added file_path and seed for actionable failure output.
25
+ """
23
26
 
24
27
  function_name: str
25
28
  passed: bool
26
29
  examples_run: int = 0
27
30
  counterexample: dict[str, Any] | None = None
28
31
  error: str | None = None
32
+ file_path: str | None = None # DX-26: For file::function format
33
+ seed: int | None = None # DX-26: Hypothesis seed for reproduction
29
34
 
30
35
 
31
36
  @dataclass
@@ -300,6 +305,10 @@ def build_test_function(
300
305
  # Build strategy dict
301
306
  strategy_dict = {}
302
307
  for param_name, strat_code in strategies.items():
308
+ # Skip functions with nothing() strategy (untestable types)
309
+ if "nothing()" in strat_code:
310
+ return None
311
+
303
312
  # Evaluate the strategy code
304
313
  try:
305
314
  # strat_code is like "st.integers(min_value=0)"
@@ -322,16 +331,62 @@ def build_test_function(
322
331
  return property_test
323
332
 
324
333
 
325
- @pre(lambda func, max_examples: callable(func) and max_examples > 0)
334
+ @pre(lambda error_str: isinstance(error_str, str))
335
+ @post(lambda result: result is None or isinstance(result, int))
336
+ def _extract_hypothesis_seed(error_str: str) -> int | None:
337
+ """Extract Hypothesis seed from error message (DX-26).
338
+
339
+ Hypothesis includes seed in output like: @seed(336048909179393285647920446708996038674)
340
+
341
+ >>> _extract_hypothesis_seed("@seed(123456)")
342
+ 123456
343
+ >>> _extract_hypothesis_seed("no seed here") is None
344
+ True
345
+ """
346
+ import re
347
+
348
+ match = re.search(r"@seed\((\d+)\)", error_str)
349
+ if match:
350
+ try:
351
+ return int(match.group(1))
352
+ except ValueError:
353
+ pass
354
+ return None
355
+
356
+
357
+ @pre(lambda name, reason: isinstance(name, str) and isinstance(reason, str))
358
+ @post(lambda result: isinstance(result, PropertyTestResult) and result.passed)
359
+ def _skip_result(name: str, reason: str) -> PropertyTestResult:
360
+ """Create a skip result (passed=True, 0 examples)."""
361
+ return PropertyTestResult(function_name=name, passed=True, examples_run=0, error=reason)
362
+
363
+
364
+ # Skip patterns for untestable error detection
365
+ _SKIP_PATTERNS = (
366
+ "Nothing", "NoSuchExample", "filter_too_much", "Could not resolve",
367
+ "validation error", "missing", "positional argument", "Unable to satisfy",
368
+ )
369
+
370
+
371
+ @pre(lambda err_str, func_name, max_examples: isinstance(err_str, str))
326
372
  @post(lambda result: isinstance(result, PropertyTestResult))
327
- def run_property_test(
328
- func: Callable,
329
- max_examples: int = 100,
373
+ def _handle_test_exception(
374
+ err_str: str, func_name: str, max_examples: int
330
375
  ) -> PropertyTestResult:
376
+ """Handle exception from property test, returning skip or failure result."""
377
+ if any(p in err_str for p in _SKIP_PATTERNS):
378
+ return _skip_result(func_name, "Skipped: untestable types")
379
+ seed = _extract_hypothesis_seed(err_str)
380
+ return PropertyTestResult(func_name, passed=False, examples_run=max_examples, error=err_str, seed=seed)
381
+
382
+
383
+ @pre(lambda func, max_examples: callable(func) and max_examples > 0)
384
+ @post(lambda result: isinstance(result, PropertyTestResult))
385
+ def run_property_test(func: Callable, max_examples: int = 100) -> PropertyTestResult:
331
386
  """
332
387
  Run a property test on a single function.
333
388
 
334
- Generates strategies from contracts and runs Hypothesis.
389
+ Uses deal.cases() which respects @pre conditions and generates valid inputs.
335
390
 
336
391
  >>> from deal import pre, post
337
392
  >>> @pre(lambda x: x >= 0)
@@ -344,40 +399,26 @@ def run_property_test(
344
399
  """
345
400
  func_name = getattr(func, "__name__", "unknown")
346
401
 
347
- # Generate test
348
- generated = generate_property_test(func)
349
- if generated is None:
350
- return PropertyTestResult(
351
- function_name=func_name,
352
- passed=True, # No test generated = skip, not fail
353
- examples_run=0,
354
- error="Could not generate test (no contracts or unparseable)",
355
- )
356
-
357
- # Build executable test
358
- test_fn = build_test_function(func, generated.strategies, max_examples)
359
- if test_fn is None:
360
- return PropertyTestResult(
361
- function_name=func_name,
362
- passed=True,
363
- examples_run=0,
364
- error="Could not build test (hypothesis not available or strategy error)",
365
- )
366
-
367
- # Run the test
368
402
  try:
369
- test_fn()
370
- return PropertyTestResult(
371
- function_name=func_name,
372
- passed=True,
373
- examples_run=max_examples,
403
+ import deal
404
+ from hypothesis import HealthCheck, Verbosity, settings
405
+
406
+ # DX-26: Suppress Hypothesis output (seed messages) for clean JSON
407
+ test_settings = settings(
408
+ max_examples=max_examples,
409
+ suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow],
410
+ verbosity=Verbosity.quiet,
374
411
  )
412
+ test_case = deal.cases(func, count=max_examples, settings=test_settings)
413
+ test_case()
414
+ return PropertyTestResult(func_name, passed=True, examples_run=max_examples)
415
+ except deal.PreContractError:
416
+ return _skip_result(func_name, "Skipped: could not generate valid inputs")
417
+ except deal.PostContractError as e:
418
+ err_str = str(e)
419
+ seed = _extract_hypothesis_seed(err_str)
420
+ return PropertyTestResult(func_name, passed=False, examples_run=max_examples, error=err_str, seed=seed)
421
+ except ImportError:
422
+ pass # Fall through to custom strategy approach
375
423
  except Exception as e:
376
- # Extract counterexample if available
377
- error_str = str(e)
378
- return PropertyTestResult(
379
- function_name=func_name,
380
- passed=False,
381
- examples_run=max_examples,
382
- error=error_str,
383
- )
424
+ return _handle_test_exception(str(e), func_name, max_examples)
invar/core/purity.py CHANGED
@@ -18,11 +18,13 @@ from deal import pre
18
18
  from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
19
19
 
20
20
  # Known impure functions and method patterns
21
+ # MINOR-3: "time" matches `from time import time; time()` which IS impure.
22
+ # May false positive on local functions named `time`, but this is rare.
21
23
  IMPURE_FUNCTIONS: set[str] = {
22
24
  "now",
23
25
  "today",
24
26
  "utcnow",
25
- "time",
27
+ "time", # from time import time
26
28
  "random",
27
29
  "randint",
28
30
  "randrange",
@@ -155,7 +157,12 @@ def extract_function_calls(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list
155
157
 
156
158
  @pre(lambda call: isinstance(call, ast.Call) and hasattr(call, "func"))
157
159
  def _get_call_name(call: ast.Call) -> str | None:
158
- """Get the name of a function call as a string."""
160
+ """Get the name of a function call as a string.
161
+
162
+ MINOR-4 Limitation: Only handles one level of attribute access (obj.method).
163
+ Chained access like a.b.method() returns None. This is acceptable since
164
+ IMPURE_PATTERNS only contains two-level patterns like ("datetime", "now").
165
+ """
159
166
  func = call.func
160
167
 
161
168
  # Simple name: print(), open()
@@ -268,8 +275,7 @@ def count_doctest_lines(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
268
275
  count += 1 # Continuation line
269
276
  elif in_doctest and stripped and not stripped.startswith(">>>"):
270
277
  count += 1 # Expected output line
271
- if not stripped: # Empty line ends output
272
- in_doctest = False
278
+ # Note: Empty line ends doctest, handled by else branch below
273
279
  else:
274
280
  in_doctest = False
275
281
  return count