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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. invar/core/contracts.py +75 -5
  2. invar/core/entry_points.py +294 -0
  3. invar/core/format_specs.py +196 -0
  4. invar/core/format_strategies.py +197 -0
  5. invar/core/formatter.py +27 -4
  6. invar/core/hypothesis_strategies.py +47 -5
  7. invar/core/lambda_helpers.py +1 -0
  8. invar/core/models.py +23 -17
  9. invar/core/parser.py +6 -2
  10. invar/core/property_gen.py +81 -40
  11. invar/core/purity.py +10 -4
  12. invar/core/review_trigger.py +298 -0
  13. invar/core/rule_meta.py +61 -2
  14. invar/core/rules.py +83 -19
  15. invar/core/shell_analysis.py +252 -0
  16. invar/core/shell_architecture.py +171 -0
  17. invar/core/suggestions.py +6 -0
  18. invar/core/tautology.py +1 -0
  19. invar/core/utils.py +51 -4
  20. invar/core/verification_routing.py +158 -0
  21. invar/invariant.py +1 -0
  22. invar/mcp/server.py +20 -3
  23. invar/shell/cli.py +59 -31
  24. invar/shell/config.py +259 -10
  25. invar/shell/fs.py +5 -2
  26. invar/shell/git.py +2 -0
  27. invar/shell/guard_helpers.py +78 -3
  28. invar/shell/guard_output.py +100 -24
  29. invar/shell/init_cmd.py +27 -7
  30. invar/shell/mcp_config.py +3 -0
  31. invar/shell/mutate_cmd.py +184 -0
  32. invar/shell/mutation.py +314 -0
  33. invar/shell/perception.py +2 -0
  34. invar/shell/property_tests.py +17 -2
  35. invar/shell/prove.py +35 -3
  36. invar/shell/prove_accept.py +113 -0
  37. invar/shell/prove_fallback.py +148 -46
  38. invar/shell/templates.py +34 -0
  39. invar/shell/test_cmd.py +3 -1
  40. invar/shell/testing.py +6 -17
  41. invar/shell/update_cmd.py +2 -0
  42. invar/templates/CLAUDE.md.template +65 -9
  43. invar/templates/INVAR.md +96 -23
  44. invar/templates/aider.conf.yml.template +16 -14
  45. invar/templates/commands/review.md +200 -0
  46. invar/templates/cursorrules.template +22 -13
  47. invar/templates/examples/contracts.py +3 -1
  48. invar/templates/examples/core_shell.py +3 -1
  49. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/METADATA +81 -15
  50. invar_tools-1.2.0.dist-info/RECORD +77 -0
  51. invar_tools-1.2.0.dist-info/licenses/LICENSE +190 -0
  52. invar_tools-1.2.0.dist-info/licenses/LICENSE-GPL +674 -0
  53. invar_tools-1.2.0.dist-info/licenses/NOTICE +63 -0
  54. invar_tools-1.0.0.dist-info/RECORD +0 -64
  55. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  56. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/WHEEL +0 -0
  57. {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/entry_points.txt +0 -0
invar/core/contracts.py CHANGED
@@ -23,15 +23,19 @@ from invar.core.tautology import check_semantic_tautology as check_semantic_taut
23
23
  from invar.core.tautology import is_semantic_tautology as is_semantic_tautology
24
24
 
25
25
 
26
- @pre(lambda expression: ("lambda" in expression and ":" in expression) or not expression.strip())
26
+ @pre(lambda expression: isinstance(expression, str))
27
27
  def is_empty_contract(expression: str) -> bool:
28
28
  """Check if a contract expression is always True (tautological).
29
29
 
30
+ Handles any string input - non-lambda expressions return False.
31
+
30
32
  Examples:
31
33
  >>> is_empty_contract("lambda: True"), is_empty_contract("lambda x: True")
32
34
  (True, True)
33
35
  >>> is_empty_contract("lambda x: x > 0"), is_empty_contract("")
34
36
  (False, False)
37
+ >>> is_empty_contract("not a lambda") # Non-lambda returns False
38
+ False
35
39
  """
36
40
  if not expression.strip():
37
41
  return False
@@ -47,7 +51,7 @@ def is_empty_contract(expression: str) -> bool:
47
51
  return False
48
52
 
49
53
 
50
- @pre(lambda expression, annotations: ("lambda" in expression and ":" in expression) or not expression.strip())
54
+ @pre(lambda expression, annotations: isinstance(expression, str))
51
55
  def is_redundant_type_contract(expression: str, annotations: dict[str, str]) -> bool:
52
56
  """Check if a contract only checks types already in annotations.
53
57
 
@@ -75,7 +79,11 @@ def is_redundant_type_contract(expression: str, annotations: dict[str, str]) ->
75
79
  @pre(lambda node: isinstance(node, ast.expr))
76
80
  @post(lambda result: result is None or isinstance(result, list))
77
81
  def _extract_isinstance_checks(node: ast.expr) -> list[tuple[str, str]] | None:
78
- """Extract isinstance checks. Returns None if other logic present."""
82
+ """Extract isinstance checks. Returns None if other logic present.
83
+
84
+ Conservative: returns None for complex expressions (nested BoolOp, etc.)
85
+ to avoid false positives when detecting redundant type contracts.
86
+ """
79
87
  if isinstance(node, ast.Call) and hasattr(node, 'func'):
80
88
  check = _parse_isinstance_call(node)
81
89
  return [check] if check else None
@@ -106,9 +114,19 @@ def _parse_isinstance_call(node: ast.Call) -> tuple[str, str] | None:
106
114
  def _types_match(annotation: str, type_name: str) -> bool:
107
115
  """Check if type annotation matches isinstance check.
108
116
 
117
+ Handles simple cases like 'int' matching 'int' and 'list[int]' matching 'list'.
118
+
119
+ MINOR-1 Limitation: Does not handle complex generics:
120
+ - Optional[T] / Union[T, None] → doesn't match 'T' or 'NoneType'
121
+ - Union[A, B] → doesn't match 'A' or 'B'
122
+ - Capitalized builtins → 'List[int]' won't match 'list'
123
+ This is acceptable for detecting obvious redundant type checks.
124
+
109
125
  Examples:
110
126
  >>> _types_match("int", "int"), _types_match("list[int]", "list")
111
127
  (True, True)
128
+ >>> _types_match("Optional[int]", "int") # Limitation: returns False
129
+ False
112
130
  """
113
131
  if annotation == type_name:
114
132
  return True
@@ -119,7 +137,7 @@ def _types_match(annotation: str, type_name: str) -> bool:
119
137
  # Phase 8.3: Parameter mismatch detection
120
138
 
121
139
 
122
- @pre(lambda expression, signature: ("lambda" in expression and ":" in expression) or not expression.strip())
140
+ @pre(lambda expression, signature: isinstance(expression, str) and isinstance(signature, str))
123
141
  def has_unused_params(expression: str, signature: str) -> tuple[bool, list[str], list[str]]:
124
142
  """
125
143
  Check if lambda has params it doesn't use (P28: Partial Contract Detection).
@@ -171,7 +189,7 @@ def has_unused_params(expression: str, signature: str) -> tuple[bool, list[str],
171
189
  return (len(unused_params) > 0, unused_params, used_params)
172
190
 
173
191
 
174
- @pre(lambda expression, signature: ("lambda" in expression and ":" in expression) or not expression.strip())
192
+ @pre(lambda expression, signature: isinstance(expression, str) and isinstance(signature, str))
175
193
  def has_param_mismatch(expression: str, signature: str) -> tuple[bool, str]:
176
194
  """
177
195
  Check if lambda params don't match function params.
@@ -373,3 +391,55 @@ def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Viol
373
391
  )
374
392
  )
375
393
  return violations
394
+
395
+
396
+ @pre(lambda file_info, config: isinstance(file_info, FileInfo))
397
+ def check_skip_without_reason(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
398
+ """
399
+ Check that @skip_property_test decorators have a reason.
400
+
401
+ DX-28: Prevent abuse of skip by requiring justification.
402
+
403
+ Examples:
404
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
405
+ >>> sym = Symbol(name="f", kind=SymbolKind.FUNCTION, line=2, end_line=5)
406
+ >>> info = FileInfo(path="test.py", lines=10, symbols=[sym], source="@skip_property_test\\ndef f(): pass")
407
+ >>> vs = check_skip_without_reason(info, RuleConfig())
408
+ >>> len(vs) > 0
409
+ True
410
+ >>> vs[0].rule
411
+ 'skip_without_reason'
412
+ >>> # MAJOR-10: Also detects empty string reasons
413
+ >>> info2 = FileInfo(path="t.py", lines=5, symbols=[sym], source='@skip_property_test("")\\ndef f(): pass')
414
+ >>> len(check_skip_without_reason(info2, RuleConfig())) > 0
415
+ True
416
+ """
417
+ violations: list[Violation] = []
418
+
419
+ source = file_info.source or ""
420
+ if "@skip_property_test" not in source:
421
+ return violations
422
+
423
+ # Pattern matches @skip_property_test at start of line (not in strings)
424
+ # Uses ^ to ensure we're matching decorator position, not string literals
425
+ bare_pattern = re.compile(r"^\s*@skip_property_test\s*$")
426
+ no_reason_pattern = re.compile(r"^\s*@skip_property_test\s*\(\s*\)\s*$")
427
+ # MAJOR-10: Also detect empty/whitespace-only string reasons
428
+ empty_string_pattern = re.compile(r"^\s*@skip_property_test\s*\(\s*['\"][\s]*['\"]\s*\)\s*$")
429
+
430
+ for line_num, line in enumerate(source.split("\n"), 1):
431
+ # Check for bare @skip_property_test, empty parens, or empty string reason
432
+ if bare_pattern.match(line) or no_reason_pattern.match(line) or empty_string_pattern.match(line):
433
+ violations.append(
434
+ Violation(
435
+ rule="skip_without_reason",
436
+ severity=Severity.WARNING,
437
+ file=file_info.path,
438
+ line=line_num,
439
+ message="@skip_property_test used without reason",
440
+ suggestion='Add reason: @skip_property_test("category: explanation")\n'
441
+ "Valid categories: no_params, strategy_factory, external_io, non_deterministic",
442
+ )
443
+ )
444
+
445
+ return violations
@@ -0,0 +1,294 @@
1
+ """
2
+ Entry point detection for framework callbacks.
3
+
4
+ DX-23: Detects entry points (Flask routes, Typer commands, pytest fixtures, etc.)
5
+ that are exempt from Result[T, E] requirement but must remain thin.
6
+
7
+ Core module: pure logic, no I/O.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import TYPE_CHECKING
14
+
15
+ from deal import post, pre
16
+
17
+ if TYPE_CHECKING:
18
+ from invar.core.models import Symbol
19
+
20
+
21
+ # Decorator patterns that indicate framework entry points
22
+ # These functions interface with external frameworks and cannot return Result
23
+ ENTRY_POINT_DECORATORS: frozenset[str] = frozenset([
24
+ # Web frameworks - Flask
25
+ "app.route",
26
+ "app.get",
27
+ "app.post",
28
+ "app.put",
29
+ "app.delete",
30
+ "app.patch",
31
+ "blueprint.route",
32
+ "bp.route",
33
+ # Web frameworks - FastAPI
34
+ "router.get",
35
+ "router.post",
36
+ "router.put",
37
+ "router.delete",
38
+ "router.patch",
39
+ "api_router.get",
40
+ "api_router.post",
41
+ "api_router.put",
42
+ "api_router.delete",
43
+ # CLI frameworks - Typer
44
+ "app.command",
45
+ "app.callback",
46
+ "typer.command",
47
+ # CLI frameworks - Click
48
+ "click.command",
49
+ "click.group",
50
+ "cli.command",
51
+ # Testing - pytest
52
+ "pytest.fixture",
53
+ "fixture",
54
+ # Event handlers
55
+ "on_event",
56
+ "app.on_event",
57
+ "middleware",
58
+ "app.middleware",
59
+ # Django
60
+ "admin.register",
61
+ "receiver",
62
+ ])
63
+
64
+ # Explicit marker comment for edge cases
65
+ ENTRY_MARKER_PATTERN = re.compile(r"#\s*@shell:entry\b")
66
+
67
+ # DX-22: Unified escape hatch pattern: # @invar:allow <rule>: <reason>
68
+ INVAR_ALLOW_PATTERN = re.compile(r"#\s*@invar:allow\s+(\w+)\s*:\s*(.+)")
69
+
70
+
71
+ @pre(lambda source: isinstance(source, str))
72
+ @post(lambda result: isinstance(result, int) and result >= 0)
73
+ def count_escape_hatches(source: str) -> int:
74
+ """
75
+ Count @invar:allow markers in source code (DX-31).
76
+
77
+ Used by check_review_suggested to trigger review when escape count >= 3.
78
+
79
+ Examples:
80
+ >>> count_escape_hatches("")
81
+ 0
82
+ >>> count_escape_hatches("# @invar:allow rule: reason")
83
+ 1
84
+ >>> source = '''
85
+ ... # @invar:allow rule1: reason1
86
+ ... def foo(): pass
87
+ ... # @invar:allow rule2: reason2
88
+ ... def bar(): pass
89
+ ... '''
90
+ >>> count_escape_hatches(source)
91
+ 2
92
+ >>> count_escape_hatches("regular comment # no marker")
93
+ 0
94
+ """
95
+ return len(INVAR_ALLOW_PATTERN.findall(source))
96
+
97
+
98
+ @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
99
+ @post(lambda result: isinstance(result, bool))
100
+ def is_entry_point(symbol: Symbol, source: str) -> bool:
101
+ """
102
+ Check if a symbol is a framework entry point.
103
+
104
+ Entry points are functions decorated with framework-specific decorators
105
+ (Flask routes, Typer commands, etc.) that cannot return Result[T, E]
106
+ because the framework expects specific return types.
107
+
108
+ Examples:
109
+ >>> from invar.core.models import Symbol, SymbolKind
110
+ >>> sym = Symbol(name="index", kind=SymbolKind.FUNCTION, line=5, end_line=10)
111
+ >>> source = '''
112
+ ... @app.route("/")
113
+ ... def index():
114
+ ... return "Hello"
115
+ ... '''
116
+ >>> is_entry_point(sym, source)
117
+ True
118
+
119
+ >>> sym2 = Symbol(name="load_file", kind=SymbolKind.FUNCTION, line=1, end_line=5)
120
+ >>> source2 = '''
121
+ ... def load_file(path: str) -> Result[str, str]:
122
+ ... return Success(path.read_text())
123
+ ... '''
124
+ >>> is_entry_point(sym2, source2)
125
+ False
126
+
127
+ >>> # Explicit marker
128
+ >>> sym3 = Symbol(name="handler", kind=SymbolKind.FUNCTION, line=3, end_line=8)
129
+ >>> source3 = '''
130
+ ... # @shell:entry - Legacy callback
131
+ ... def handler(data):
132
+ ... return process(data)
133
+ ... '''
134
+ >>> is_entry_point(sym3, source3)
135
+ True
136
+ """
137
+ # Check decorator patterns
138
+ if _has_entry_decorator(symbol, source):
139
+ return True
140
+
141
+ # Check explicit marker
142
+ return _has_entry_marker(symbol, source)
143
+
144
+
145
+ @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
146
+ @post(lambda result: isinstance(result, bool))
147
+ def _has_entry_decorator(symbol: Symbol, source: str) -> bool:
148
+ """
149
+ Check if symbol has a framework entry point decorator.
150
+
151
+ Looks at the source code above the function definition.
152
+
153
+ Examples:
154
+ >>> from invar.core.models import Symbol, SymbolKind
155
+ >>> sym = Symbol(name="home", kind=SymbolKind.FUNCTION, line=3, end_line=6)
156
+ >>> source = '''@app.route("/")
157
+ ... def home():
158
+ ... pass
159
+ ... '''
160
+ >>> _has_entry_decorator(sym, source)
161
+ True
162
+ """
163
+ # Get source lines
164
+ lines = source.splitlines()
165
+ if not lines:
166
+ return False
167
+
168
+ # Look at lines before the function definition (decorators are above)
169
+ # We check up to 5 lines above the function for decorators
170
+ start_line = max(0, symbol.line - 6)
171
+ end_line = symbol.line # Line numbers are 1-indexed, so line-1 = index
172
+
173
+ context_lines = lines[start_line:end_line]
174
+ context = "\n".join(context_lines)
175
+
176
+ # Check each known decorator pattern
177
+ # Note: String matching may match decorators in string literals (rare edge case).
178
+ # AST-based detection would be more robust but adds complexity for a heuristic check.
179
+ for pattern in ENTRY_POINT_DECORATORS:
180
+ # Match @pattern or @something.pattern
181
+ if f"@{pattern}" in context:
182
+ return True
183
+ # Also match partial patterns (e.g., "route" matches "app.route")
184
+ if "." in pattern:
185
+ base = pattern.split(".")[-1]
186
+ if f".{base}(" in context or f".{base}\n" in context:
187
+ return True
188
+
189
+ return False
190
+
191
+
192
+ @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
193
+ @post(lambda result: isinstance(result, bool))
194
+ def _has_entry_marker(symbol: Symbol, source: str) -> bool:
195
+ """
196
+ Check if symbol has an explicit entry point marker comment.
197
+
198
+ Looks for: # @shell:entry
199
+
200
+ Examples:
201
+ >>> from invar.core.models import Symbol, SymbolKind
202
+ >>> sym = Symbol(name="callback", kind=SymbolKind.FUNCTION, line=3, end_line=6)
203
+ >>> source = '''
204
+ ... # @shell:entry - Custom framework callback
205
+ ... def callback():
206
+ ... pass
207
+ ... '''
208
+ >>> _has_entry_marker(sym, source)
209
+ True
210
+
211
+ >>> sym2 = Symbol(name="regular", kind=SymbolKind.FUNCTION, line=1, end_line=3)
212
+ >>> source2 = '''def regular(): pass'''
213
+ >>> _has_entry_marker(sym2, source2)
214
+ False
215
+ """
216
+ lines = source.splitlines()
217
+ if not lines:
218
+ return False
219
+
220
+ # Look at lines before the function definition
221
+ start_line = max(0, symbol.line - 4)
222
+ end_line = symbol.line
223
+
224
+ context_lines = lines[start_line:end_line]
225
+ context = "\n".join(context_lines)
226
+
227
+ return bool(ENTRY_MARKER_PATTERN.search(context))
228
+
229
+
230
+ @pre(lambda symbol: symbol is not None)
231
+ @post(lambda result: isinstance(result, int) and result >= 0)
232
+ def get_symbol_lines(symbol: Symbol) -> int:
233
+ """
234
+ Get the number of lines in a symbol.
235
+
236
+ Examples:
237
+ >>> from invar.core.models import Symbol, SymbolKind
238
+ >>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=10)
239
+ >>> get_symbol_lines(sym)
240
+ 10
241
+ >>> sym2 = Symbol(name="bar", kind=SymbolKind.FUNCTION, line=5, end_line=5)
242
+ >>> get_symbol_lines(sym2)
243
+ 1
244
+ """
245
+ return max(1, symbol.end_line - symbol.line + 1)
246
+
247
+
248
+ @pre(lambda symbol, source, rule: symbol is not None and isinstance(rule, str))
249
+ @post(lambda result: isinstance(result, bool))
250
+ def has_allow_marker(symbol: Symbol, source: str, rule: str) -> bool:
251
+ """
252
+ Check if symbol has an @invar:allow marker for a specific rule.
253
+
254
+ DX-22: Unified escape hatch mechanism. Format:
255
+ # @invar:allow <rule>: <reason>
256
+
257
+ Examples:
258
+ >>> from invar.core.models import Symbol, SymbolKind
259
+ >>> sym = Symbol(name="handler", kind=SymbolKind.FUNCTION, line=3, end_line=20)
260
+ >>> source = '''
261
+ ... # @invar:allow entry_point_too_thick: Complex CLI parsing
262
+ ... def handler():
263
+ ... pass
264
+ ... '''
265
+ >>> has_allow_marker(sym, source, "entry_point_too_thick")
266
+ True
267
+ >>> has_allow_marker(sym, source, "shell_result")
268
+ False
269
+
270
+ >>> sym2 = Symbol(name="api", kind=SymbolKind.FUNCTION, line=3, end_line=10)
271
+ >>> source2 = '''
272
+ ... # @invar:allow shell_result: Returns raw JSON for legacy API
273
+ ... def api():
274
+ ... pass
275
+ ... '''
276
+ >>> has_allow_marker(sym2, source2, "shell_result")
277
+ True
278
+ """
279
+ lines = source.splitlines()
280
+ if not lines:
281
+ return False
282
+
283
+ # Look at lines before the function definition (up to 4 lines)
284
+ start_line = max(0, symbol.line - 5)
285
+ end_line = symbol.line
286
+
287
+ context_lines = lines[start_line:end_line]
288
+
289
+ for line in context_lines:
290
+ match = INVAR_ALLOW_PATTERN.search(line)
291
+ if match and match.group(1) == rule:
292
+ return True
293
+
294
+ return False
@@ -0,0 +1,196 @@
1
+ """
2
+ Format-driven property testing specifications.
3
+
4
+ DX-28: Format specifications for generating realistic test data
5
+ that matches production formats, enabling property tests to catch
6
+ semantic bugs like inverted filter conditions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from deal import post, pre
14
+
15
+
16
+ @dataclass
17
+ class FormatSpec:
18
+ """Base specification for a data format.
19
+
20
+ A FormatSpec describes the structure of data used in a function,
21
+ enabling Hypothesis to generate realistic test cases.
22
+
23
+ Examples:
24
+ >>> spec = FormatSpec(name="simple")
25
+ >>> spec.name
26
+ 'simple'
27
+ """
28
+
29
+ name: str
30
+ description: str = ""
31
+
32
+
33
+ @dataclass
34
+ class LineFormat(FormatSpec):
35
+ """Specification for line-based text formats.
36
+
37
+ Examples:
38
+ >>> spec = LineFormat(
39
+ ... name="log_line",
40
+ ... prefix_pattern="<timestamp>",
41
+ ... separator=": ",
42
+ ... keywords=["error", "warning", "info"]
43
+ ... )
44
+ >>> spec.separator
45
+ ': '
46
+ """
47
+
48
+ prefix_pattern: str = ""
49
+ separator: str = ""
50
+ keywords: list[str] = field(default_factory=list)
51
+ suffix_pattern: str = ""
52
+
53
+
54
+ @dataclass
55
+ class CrossHairOutputSpec(FormatSpec):
56
+ """Specification for CrossHair verification output format.
57
+
58
+ CrossHair outputs counterexamples in a specific format:
59
+ `file.py:line: error: ErrorType when calling func(args)`
60
+
61
+ This spec enables generating realistic CrossHair output for testing
62
+ counterexample extraction logic.
63
+
64
+ Examples:
65
+ >>> spec = CrossHairOutputSpec()
66
+ >>> spec.name
67
+ 'crosshair_output'
68
+ >>> ": error:" in spec.error_marker
69
+ True
70
+ """
71
+
72
+ name: str = "crosshair_output"
73
+ description: str = "CrossHair symbolic verification output"
74
+
75
+ # Format components
76
+ file_pattern: str = r"[a-z_]+\.py"
77
+ line_pattern: str = r"\\d+"
78
+ error_marker: str = ": error:"
79
+ error_types: list[str] = field(
80
+ default_factory=lambda: [
81
+ "IndexError",
82
+ "KeyError",
83
+ "ValueError",
84
+ "TypeError",
85
+ "AssertionError",
86
+ "ZeroDivisionError",
87
+ "AttributeError",
88
+ ]
89
+ )
90
+
91
+ @pre(lambda self, filename, line, error_type, function, args: self.error_marker and line > 0)
92
+ @post(lambda result: isinstance(result, str) and len(result) > 0)
93
+ def format_counterexample(
94
+ self,
95
+ filename: str = "test.py",
96
+ line: int = 1,
97
+ error_type: str = "AssertionError",
98
+ function: str = "func",
99
+ args: str = "x=0",
100
+ ) -> str:
101
+ """
102
+ Generate a counterexample line in CrossHair format.
103
+
104
+ Examples:
105
+ >>> spec = CrossHairOutputSpec()
106
+ >>> line = spec.format_counterexample("foo.py", 42, "ValueError", "bar", "x=-1")
107
+ >>> line
108
+ 'foo.py:42: error: ValueError when calling bar(x=-1)'
109
+ >>> ": error:" in line
110
+ True
111
+ """
112
+ return f"{filename}:{line}: error: {error_type} when calling {function}({args})"
113
+
114
+ @pre(lambda self, count, include_success, include_errors: count >= 0 and (not include_errors or (len(self.error_types) > 0 and bool(self.error_marker))))
115
+ @post(lambda result: isinstance(result, list))
116
+ def generate_output(
117
+ self,
118
+ count: int = 3,
119
+ include_success: bool = True,
120
+ include_errors: bool = True,
121
+ ) -> list[str]:
122
+ """
123
+ Generate sample CrossHair output with counterexamples.
124
+
125
+ Examples:
126
+ >>> spec = CrossHairOutputSpec()
127
+ >>> output = spec.generate_output(count=2, include_success=False, include_errors=True)
128
+ >>> len([l for l in output if ": error:" in l])
129
+ 2
130
+ >>> all(": error:" in line for line in output if line)
131
+ True
132
+ """
133
+ lines: list[str] = []
134
+
135
+ if include_success:
136
+ lines.append("Checking module...")
137
+
138
+ if include_errors:
139
+ for i in range(count):
140
+ error_type = self.error_types[i % len(self.error_types)]
141
+ lines.append(
142
+ self.format_counterexample(
143
+ f"file{i}.py", i + 1, error_type, f"func{i}", f"x={i}"
144
+ )
145
+ )
146
+
147
+ if include_success:
148
+ lines.append("") # Empty line at end
149
+
150
+ return lines
151
+
152
+
153
+ @dataclass
154
+ class PytestOutputSpec(FormatSpec):
155
+ """Specification for pytest output format.
156
+
157
+ Examples:
158
+ >>> spec = PytestOutputSpec()
159
+ >>> spec.name
160
+ 'pytest_output'
161
+ """
162
+
163
+ name: str = "pytest_output"
164
+ description: str = "pytest test runner output"
165
+
166
+ passed_marker: str = "PASSED"
167
+ failed_marker: str = "FAILED"
168
+ error_marker: str = "ERROR"
169
+ separator: str = "::"
170
+
171
+
172
+ # Pre-built specs for common formats
173
+ CROSSHAIR_SPEC = CrossHairOutputSpec()
174
+ PYTEST_SPEC = PytestOutputSpec()
175
+
176
+
177
+ @pre(lambda text, spec: isinstance(text, str) and isinstance(spec, CrossHairOutputSpec))
178
+ @post(lambda result: isinstance(result, list))
179
+ def extract_by_format(text: str, spec: CrossHairOutputSpec) -> list[str]:
180
+ """
181
+ Extract lines matching a format specification.
182
+
183
+ This is a reference implementation showing how FormatSpec
184
+ can be used to create format-aware extraction.
185
+
186
+ Examples:
187
+ >>> spec = CrossHairOutputSpec()
188
+ >>> text = "info\\nfile.py:1: error: Bug\\nok"
189
+ >>> extract_by_format(text, spec)
190
+ ['file.py:1: error: Bug']
191
+ """
192
+ return [
193
+ line.strip()
194
+ for line in text.split("\n")
195
+ if line.strip() and spec.error_marker in line.lower()
196
+ ]