invar-tools 1.17.26__py3-none-any.whl → 1.17.27__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 (35) hide show
  1. invar/core/contracts.py +38 -6
  2. invar/core/doc_edit.py +22 -9
  3. invar/core/doc_parser.py +17 -11
  4. invar/core/entry_points.py +48 -42
  5. invar/core/extraction.py +25 -9
  6. invar/core/format_specs.py +7 -2
  7. invar/core/format_strategies.py +6 -4
  8. invar/core/formatter.py +9 -6
  9. invar/core/hypothesis_strategies.py +24 -5
  10. invar/core/lambda_helpers.py +2 -2
  11. invar/core/parser.py +40 -21
  12. invar/core/patterns/detector.py +25 -6
  13. invar/core/patterns/p0_exhaustive.py +5 -5
  14. invar/core/patterns/p0_literal.py +8 -8
  15. invar/core/patterns/p0_newtype.py +2 -2
  16. invar/core/patterns/p0_nonempty.py +12 -7
  17. invar/core/patterns/p0_validation.py +4 -8
  18. invar/core/patterns/registry.py +12 -2
  19. invar/core/property_gen.py +47 -23
  20. invar/core/shell_analysis.py +70 -66
  21. invar/core/strategies.py +14 -3
  22. invar/core/suggestions.py +12 -4
  23. invar/core/tautology.py +33 -10
  24. invar/core/template_parser.py +23 -15
  25. invar/core/ts_parsers.py +6 -2
  26. invar/core/ts_sig_parser.py +18 -10
  27. invar/core/utils.py +20 -9
  28. invar/templates/protocol/python/tools.md +3 -0
  29. {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/METADATA +1 -1
  30. {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/RECORD +35 -35
  31. {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/WHEEL +0 -0
  32. {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/entry_points.txt +0 -0
  33. {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/licenses/LICENSE +0 -0
  34. {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/licenses/LICENSE-GPL +0 -0
  35. {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/licenses/NOTICE +0 -0
invar/core/contracts.py CHANGED
@@ -76,7 +76,7 @@ def is_redundant_type_contract(expression: str, annotations: dict[str, str]) ->
76
76
  return False
77
77
 
78
78
 
79
- @pre(lambda node: isinstance(node, ast.expr) and hasattr(node, '__class__'))
79
+ @pre(lambda node: isinstance(node, ast.expr) and hasattr(node, "__class__"))
80
80
  @post(lambda result: result is None or isinstance(result, list))
81
81
  def _extract_isinstance_checks(node: ast.expr) -> list[tuple[str, str]] | None:
82
82
  """Extract isinstance checks. Returns None if other logic present.
@@ -84,17 +84,21 @@ def _extract_isinstance_checks(node: ast.expr) -> list[tuple[str, str]] | None:
84
84
  Conservative: returns None for complex expressions (nested BoolOp, etc.)
85
85
  to avoid false positives when detecting redundant type contracts.
86
86
  """
87
- if isinstance(node, ast.Call) and hasattr(node, 'func'):
87
+ if isinstance(node, ast.Call) and hasattr(node, "func"):
88
88
  check = _parse_isinstance_call(node)
89
89
  return [check] if check else None
90
- if isinstance(node, ast.BoolOp) and hasattr(node, 'op') and isinstance(node.op, ast.And):
91
- valid_calls = [v for v in node.values if isinstance(v, ast.Call) and hasattr(v, 'func') and hasattr(v, 'args')]
90
+ if isinstance(node, ast.BoolOp) and hasattr(node, "op") and isinstance(node.op, ast.And):
91
+ valid_calls = [
92
+ v
93
+ for v in node.values
94
+ if isinstance(v, ast.Call) and hasattr(v, "func") and hasattr(v, "args")
95
+ ]
92
96
  checks = [_parse_isinstance_call(v) for v in valid_calls]
93
97
  return checks if len(checks) == len(node.values) and all(checks) else None
94
98
  return None
95
99
 
96
100
 
97
- @pre(lambda node: isinstance(node, ast.Call) and hasattr(node, 'func') and hasattr(node, 'args'))
101
+ @pre(lambda node: isinstance(node, ast.Call) and hasattr(node, "func") and hasattr(node, "args"))
98
102
  @post(lambda result: result is None or (isinstance(result, tuple) and len(result) == 2))
99
103
  def _parse_isinstance_call(node: ast.Call) -> tuple[str, str] | None:
100
104
  """Parse isinstance(x, Type) call. Returns (param, type) or None."""
@@ -353,6 +357,9 @@ def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Viol
353
357
  - param_mismatch: lambda param COUNT != function param count (ERROR)
354
358
  - partial_contract: lambda has all params but doesn't USE all (WARN)
355
359
 
360
+ Note: For methods, 'self' and 'cls' are automatically excluded from unused params check,
361
+ since these are instance/class references that rarely need @pre constraints.
362
+
356
363
  Examples:
357
364
  >>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract, RuleConfig
358
365
  >>> c = Contract(kind="pre", expression="lambda x, y: x > 0", line=1)
@@ -364,6 +371,23 @@ def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Viol
364
371
  <Severity.WARNING: 'warning'>
365
372
  >>> "y" in vs[0].message
366
373
  True
374
+
375
+ Method's self is excluded from unused check:
376
+ >>> c2 = Contract(kind="pre", expression="lambda self, x, y: x > 0", line=1)
377
+ >>> m = Symbol(name="calc", kind=SymbolKind.METHOD, line=1, end_line=5, signature="(self, x: int, y: int) -> int", contracts=[c2])
378
+ >>> vs2 = check_partial_contract(FileInfo(path="c.py", lines=10, symbols=[m], is_core=True), RuleConfig())
379
+ >>> len(vs2)
380
+ 1
381
+ >>> "self" in vs2[0].message # self should NOT be reported
382
+ False
383
+ >>> "y" in vs2[0].message # y should still be reported
384
+ True
385
+
386
+ When only self is unused, no violation:
387
+ >>> c3 = Contract(kind="pre", expression="lambda self, x: x > 0", line=1)
388
+ >>> m2 = Symbol(name="calc", kind=SymbolKind.METHOD, line=1, end_line=5, signature="(self, x: int) -> int", contracts=[c3])
389
+ >>> check_partial_contract(FileInfo(path="c.py", lines=10, symbols=[m2], is_core=True), RuleConfig())
390
+ []
367
391
  """
368
392
  violations: list[Violation] = []
369
393
  if not file_info.is_core:
@@ -376,6 +400,10 @@ def check_partial_contract(file_info: FileInfo, config: RuleConfig) -> list[Viol
376
400
  if contract.kind != "pre":
377
401
  continue
378
402
  has_unused, unused, used = has_unused_params(contract.expression, symbol.signature)
403
+ # For methods, exclude self/cls from unused check (they rarely need @pre constraints)
404
+ if symbol.kind == SymbolKind.METHOD:
405
+ unused = [p for p in unused if p not in ("self", "cls")]
406
+ has_unused = len(unused) > 0
379
407
  if has_unused:
380
408
  kind = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
381
409
  unused_str = ", ".join(f"'{p}'" for p in unused)
@@ -429,7 +457,11 @@ def check_skip_without_reason(file_info: FileInfo, config: RuleConfig) -> list[V
429
457
 
430
458
  for line_num, line in enumerate(source.split("\n"), 1):
431
459
  # 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):
460
+ if (
461
+ bare_pattern.match(line)
462
+ or no_reason_pattern.match(line)
463
+ or empty_string_pattern.match(line)
464
+ ):
433
465
  violations.append(
434
466
  Violation(
435
467
  rule="skip_without_reason",
invar/core/doc_edit.py CHANGED
@@ -15,9 +15,14 @@ if TYPE_CHECKING:
15
15
  from invar.core.doc_parser import Section
16
16
 
17
17
 
18
- @pre(lambda source, section, new_content, keep_heading=True: section.line_start >= 1)
19
- @pre(lambda source, section, new_content, keep_heading=True: section.line_end >= section.line_start)
20
- @pre(lambda source, section, new_content, keep_heading=True: section.line_end <= len(source.split("\n")))
18
+ @pre(
19
+ lambda source, section, new_content, keep_heading=True: len(source) > 0
20
+ and isinstance(new_content, str)
21
+ and isinstance(keep_heading, bool)
22
+ and section.line_start >= 1
23
+ and section.line_end >= section.line_start
24
+ and section.line_end <= len(source.split("\n"))
25
+ )
21
26
  def replace_section(
22
27
  source: str,
23
28
  section: Section,
@@ -73,9 +78,13 @@ def replace_section(
73
78
  return "\n".join(result_lines)
74
79
 
75
80
 
76
- @pre(lambda source, anchor, content, position="after": anchor.line_start >= 1)
77
- @pre(lambda source, anchor, content, position="after": anchor.line_end <= len(source.split("\n")))
78
- @pre(lambda source, anchor, content, position="after": position in ("before", "after", "first_child", "last_child"))
81
+ @pre(
82
+ lambda source, anchor, content, position="after": len(source) > 0
83
+ and isinstance(content, str)
84
+ and anchor.line_start >= 1
85
+ and anchor.line_end <= len(source.split("\n"))
86
+ and position in ("before", "after", "first_child", "last_child")
87
+ )
79
88
  def insert_section(
80
89
  source: str,
81
90
  anchor: Section,
@@ -132,9 +141,13 @@ def insert_section(
132
141
  return "\n".join(result_lines)
133
142
 
134
143
 
135
- @pre(lambda source, section, include_children=True: section.line_start >= 1)
136
- @pre(lambda source, section, include_children=True: section.line_end >= section.line_start)
137
- @pre(lambda source, section, include_children=True: section.line_end <= len(source.split("\n")))
144
+ @pre(
145
+ lambda source, section, include_children=True: len(source) > 0
146
+ and isinstance(include_children, bool)
147
+ and section.line_start >= 1
148
+ and section.line_end >= section.line_start
149
+ and section.line_end <= len(source.split("\n"))
150
+ )
138
151
  def delete_section(source: str, section: Section, include_children: bool = True) -> str:
139
152
  """Delete a section from the document.
140
153
 
invar/core/doc_parser.py CHANGED
@@ -101,9 +101,11 @@ def _slugify(title: str) -> str:
101
101
  return slug
102
102
 
103
103
 
104
- @skip_property_test("crosshair_incompatible: Unicode character validation conflicts with symbolic execution") # type: ignore[untyped-decorator]
104
+ @skip_property_test(
105
+ "crosshair_incompatible: Unicode character validation conflicts with symbolic execution"
106
+ ) # type: ignore[untyped-decorator]
105
107
  @pre(lambda text: len(text) <= 1000)
106
- @post(lambda result: result == '' or all(c.isalnum() or c == '_' or ord(c) > 127 for c in result))
108
+ @post(lambda result: result == "" or all(c.isalnum() or c == "_" or ord(c) > 127 for c in result))
107
109
  def _normalize_for_fuzzy(text: str) -> str:
108
110
  """
109
111
  Normalize text for Unicode-aware fuzzy matching.
@@ -128,9 +130,9 @@ def _normalize_for_fuzzy(text: str) -> str:
128
130
  'apiv20'
129
131
  """
130
132
  # Convert ASCII to lowercase, keep Unicode as-is
131
- ascii_lower = ''.join(c.lower() if c.isascii() else c for c in text)
133
+ ascii_lower = "".join(c.lower() if c.isascii() else c for c in text)
132
134
  # Remove non-word-chars, but keep Unicode letters/digits (via re.UNICODE)
133
- return re.sub(r'[^\w]', '', ascii_lower, flags=re.UNICODE)
135
+ return re.sub(r"[^\w]", "", ascii_lower, flags=re.UNICODE)
134
136
 
135
137
 
136
138
  @pre(lambda sections: all(1 <= s.level <= 6 for s in sections)) # Valid heading levels
@@ -313,7 +315,7 @@ def parse_toc(source: str) -> DocumentToc:
313
315
  return DocumentToc(sections=root_sections, frontmatter=frontmatter)
314
316
 
315
317
 
316
- @pre(lambda sections, target_line: target_line >= 1)
318
+ @pre(lambda sections, target_line: isinstance(sections, list) and target_line >= 1)
317
319
  @post(lambda result: result is None or isinstance(result, Section))
318
320
  def _find_by_line(sections: list[Section], target_line: int) -> Section | None:
319
321
  """Find section by line number.
@@ -337,7 +339,7 @@ def _find_by_line(sections: list[Section], target_line: int) -> Section | None:
337
339
  return None
338
340
 
339
341
 
340
- @pre(lambda sections, path: len(path) > 0 and path.startswith("#"))
342
+ @pre(lambda sections, path: isinstance(sections, list) and len(path) > 0 and path.startswith("#"))
341
343
  @post(lambda result: result is None or isinstance(result, Section))
342
344
  def _find_by_index(sections: list[Section], path: str) -> Section | None:
343
345
  """Find section by index path (#0/#1/#2).
@@ -378,7 +380,7 @@ def _find_by_index(sections: list[Section], path: str) -> Section | None:
378
380
 
379
381
 
380
382
  @skip_property_test("crosshair_incompatible: Calls _normalize_for_fuzzy with Unicode validation") # type: ignore[untyped-decorator]
381
- @pre(lambda sections, path: len(path) > 0)
383
+ @pre(lambda sections, path: isinstance(sections, list) and len(path) > 0)
382
384
  @post(lambda result: result is None or isinstance(result, Section))
383
385
  def _find_by_slug_or_fuzzy(sections: list[Section], path: str) -> Section | None:
384
386
  """Find section by slug path or fuzzy match.
@@ -433,7 +435,7 @@ def _find_by_slug_or_fuzzy(sections: list[Section], path: str) -> Section | None
433
435
 
434
436
 
435
437
  @skip_property_test("crosshair_incompatible: Calls _find_by_slug_or_fuzzy with Unicode validation") # type: ignore[untyped-decorator]
436
- @pre(lambda sections, path: len(path) > 0)
438
+ @pre(lambda sections, path: isinstance(sections, list) and len(path) > 0)
437
439
  @post(lambda result: result is None or isinstance(result, Section))
438
440
  def find_section(sections: list[Section], path: str) -> Section | None:
439
441
  """Find section by path (slug, fuzzy, index, or line anchor).
@@ -515,9 +517,13 @@ def _get_last_line(section: Section) -> int:
515
517
  return _get_last_line(last_child)
516
518
 
517
519
 
518
- @pre(lambda source, section, include_children=True: section.line_start >= 1)
519
- @pre(lambda source, section, include_children=True: section.line_end >= section.line_start)
520
- @pre(lambda source, section, include_children=True: section.line_end <= len(source.split("\n"))) # Bounds check
520
+ @pre(
521
+ lambda source, section, include_children=True: len(source) > 0
522
+ and isinstance(include_children, bool)
523
+ and section.line_start >= 1
524
+ and section.line_end >= section.line_start
525
+ and section.line_end <= len(source.split("\n"))
526
+ )
521
527
  def extract_content(source: str, section: Section, include_children: bool = True) -> str:
522
528
  """Extract section content from source.
523
529
 
@@ -22,46 +22,48 @@ if TYPE_CHECKING:
22
22
 
23
23
  # Decorator patterns that indicate framework entry points
24
24
  # These functions interface with external frameworks and cannot return Result
25
- ENTRY_POINT_DECORATORS: frozenset[str] = frozenset([
26
- # Web frameworks - Flask
27
- "app.route",
28
- "app.get",
29
- "app.post",
30
- "app.put",
31
- "app.delete",
32
- "app.patch",
33
- "blueprint.route",
34
- "bp.route",
35
- # Web frameworks - FastAPI
36
- "router.get",
37
- "router.post",
38
- "router.put",
39
- "router.delete",
40
- "router.patch",
41
- "api_router.get",
42
- "api_router.post",
43
- "api_router.put",
44
- "api_router.delete",
45
- # CLI frameworks - Typer
46
- "app.command",
47
- "app.callback",
48
- "typer.command",
49
- # CLI frameworks - Click
50
- "click.command",
51
- "click.group",
52
- "cli.command",
53
- # Testing - pytest
54
- "pytest.fixture",
55
- "fixture",
56
- # Event handlers
57
- "on_event",
58
- "app.on_event",
59
- "middleware",
60
- "app.middleware",
61
- # Django
62
- "admin.register",
63
- "receiver",
64
- ])
25
+ ENTRY_POINT_DECORATORS: frozenset[str] = frozenset(
26
+ [
27
+ # Web frameworks - Flask
28
+ "app.route",
29
+ "app.get",
30
+ "app.post",
31
+ "app.put",
32
+ "app.delete",
33
+ "app.patch",
34
+ "blueprint.route",
35
+ "bp.route",
36
+ # Web frameworks - FastAPI
37
+ "router.get",
38
+ "router.post",
39
+ "router.put",
40
+ "router.delete",
41
+ "router.patch",
42
+ "api_router.get",
43
+ "api_router.post",
44
+ "api_router.put",
45
+ "api_router.delete",
46
+ # CLI frameworks - Typer
47
+ "app.command",
48
+ "app.callback",
49
+ "typer.command",
50
+ # CLI frameworks - Click
51
+ "click.command",
52
+ "click.group",
53
+ "cli.command",
54
+ # Testing - pytest
55
+ "pytest.fixture",
56
+ "fixture",
57
+ # Event handlers
58
+ "on_event",
59
+ "app.on_event",
60
+ "middleware",
61
+ "app.middleware",
62
+ # Django
63
+ "admin.register",
64
+ "receiver",
65
+ ]
66
+ )
65
67
 
66
68
  # Explicit marker comment for edge cases
67
69
  ENTRY_MARKER_PATTERN = re.compile(r"#\s*@shell:entry\b")
@@ -188,7 +190,6 @@ def is_entry_point(symbol: Symbol, source: str) -> bool:
188
190
  return _has_entry_marker(symbol, source)
189
191
 
190
192
 
191
-
192
193
  @post(lambda result: isinstance(result, str))
193
194
  def _decorator_to_string(decorator: ast.AST) -> str:
194
195
  """
@@ -216,6 +217,7 @@ def _decorator_to_string(decorator: ast.AST) -> str:
216
217
  return _decorator_to_string(decorator.func)
217
218
  return ""
218
219
 
220
+
219
221
  @pre(lambda symbol, source: symbol is not None and isinstance(source, str))
220
222
  @post(lambda result: isinstance(result, bool))
221
223
  def _has_entry_decorator(symbol: Symbol, source: str) -> bool:
@@ -320,7 +322,11 @@ def get_symbol_lines(symbol: Symbol) -> int:
320
322
  return max(1, symbol.end_line - symbol.line + 1)
321
323
 
322
324
 
323
- @pre(lambda symbol, source, rule: symbol is not None and isinstance(rule, str))
325
+ @pre(
326
+ lambda symbol, source, rule: symbol is not None
327
+ and isinstance(source, str)
328
+ and isinstance(rule, str)
329
+ )
324
330
  @post(lambda result: isinstance(result, bool))
325
331
  def has_allow_marker(symbol: Symbol, source: str, rule: str) -> bool:
326
332
  """
invar/core/extraction.py CHANGED
@@ -32,9 +32,16 @@ def _build_call_graph(funcs: dict[str, Symbol]) -> dict[str, set[str]]:
32
32
  return graph
33
33
 
34
34
 
35
- @pre(lambda start, graph, visited: start and start in graph) # Start must exist in graph
35
+ @pre(
36
+ lambda start, graph, visited: start
37
+ and start in graph
38
+ and isinstance(graph, dict)
39
+ and isinstance(visited, set)
40
+ ) # Start must exist in graph
36
41
  @post(lambda result: len(result) >= 1 or not result) # At least 1 if found, else empty
37
- def _find_connected_component(start: str, graph: dict[str, set[str]], visited: set[str]) -> list[str]:
42
+ def _find_connected_component(
43
+ start: str, graph: dict[str, set[str]], visited: set[str]
44
+ ) -> list[str]:
38
45
  """BFS to find all functions connected to start.
39
46
 
40
47
  >>> g = {"a": {"b"}, "b": {"a"}, "c": set()}
@@ -97,17 +104,24 @@ def find_extractable_groups(file_info: FileInfo) -> list[dict]:
97
104
  total_lines = sum(funcs[n].end_line - funcs[n].line + 1 for n in component)
98
105
  deps = _get_group_dependencies(component, funcs, file_info.imports)
99
106
 
100
- groups.append({
101
- "functions": sorted(component),
102
- "lines": total_lines,
103
- "dependencies": sorted(deps),
104
- })
107
+ groups.append(
108
+ {
109
+ "functions": sorted(component),
110
+ "lines": total_lines,
111
+ "dependencies": sorted(deps),
112
+ }
113
+ )
105
114
 
106
115
  groups.sort(key=lambda g: -g["lines"])
107
116
  return groups
108
117
 
109
118
 
110
- @pre(lambda func_names, funcs, file_imports: all(n in funcs for n in func_names if n))
119
+ @pre(
120
+ lambda func_names, funcs, file_imports: isinstance(func_names, list)
121
+ and isinstance(funcs, dict)
122
+ and isinstance(file_imports, list)
123
+ and all(n in funcs for n in func_names if n)
124
+ )
111
125
  @post(lambda result: isinstance(result, set))
112
126
  def _get_group_dependencies(
113
127
  func_names: list[str],
@@ -135,7 +149,9 @@ def _get_group_dependencies(
135
149
  return deps.intersection(set(file_imports)) if file_imports else deps
136
150
 
137
151
 
138
- @pre(lambda file_info, max_groups=3: max_groups >= 1) # At least 1 group
152
+ @pre(
153
+ lambda file_info, max_groups=3: isinstance(file_info, FileInfo) and max_groups >= 1
154
+ ) # At least 1 group
139
155
  def format_extraction_hint(file_info: FileInfo, max_groups: int = 3) -> str:
140
156
  """
141
157
  Format extraction suggestions for file_size_warning.
@@ -111,7 +111,10 @@ class CrossHairOutputSpec(FormatSpec):
111
111
  """
112
112
  return f"{filename}:{line}: error: {error_type} when calling {function}({args})"
113
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))))
114
+ @pre(
115
+ lambda self, count, include_success, include_errors: count >= 0
116
+ and (not include_errors or (len(self.error_types) > 0 and bool(self.error_marker)))
117
+ )
115
118
  @post(lambda result: isinstance(result, list))
116
119
  def generate_output(
117
120
  self,
@@ -174,7 +177,9 @@ CROSSHAIR_SPEC = CrossHairOutputSpec()
174
177
  PYTEST_SPEC = PytestOutputSpec()
175
178
 
176
179
 
177
- @post(lambda result: all(isinstance(line, str) and line.strip() for line in result)) # Non-empty strings
180
+ @post(
181
+ lambda result: all(isinstance(line, str) and line.strip() for line in result)
182
+ ) # Non-empty strings
178
183
  def extract_by_format(text: str, spec: CrossHairOutputSpec) -> list[str]:
179
184
  """
180
185
  Extract lines matching a format specification.
@@ -112,7 +112,11 @@ def crosshair_output(
112
112
  )
113
113
 
114
114
 
115
- @pre(lambda pattern, min_occurrences, max_occurrences: len(pattern) > 0 and min_occurrences >= 0)
115
+ @pre(
116
+ lambda pattern, min_occurrences, max_occurrences: len(pattern) > 0
117
+ and min_occurrences >= 0
118
+ and max_occurrences >= min_occurrences
119
+ )
116
120
  @post(lambda result: result is not None)
117
121
  def text_with_pattern(
118
122
  pattern: str,
@@ -154,9 +158,7 @@ def text_with_pattern(
154
158
  )
155
159
 
156
160
  # Combine into full output
157
- pattern_lines = st.lists(
158
- pattern_line, min_size=min_occurrences, max_size=max_occurrences
159
- )
161
+ pattern_lines = st.lists(pattern_line, min_size=min_occurrences, max_size=max_occurrences)
160
162
  noise_lines = st.lists(noise_line, min_size=0, max_size=10)
161
163
 
162
164
  # Note: Hypothesis will automatically explore different orderings of the lines,
invar/core/formatter.py CHANGED
@@ -15,7 +15,7 @@ from invar.core.models import GuardReport, PerceptionMap, Symbol, SymbolRefs, Vi
15
15
  from invar.core.rule_meta import get_rule_meta
16
16
 
17
17
 
18
- @pre(lambda perception_map, top_n=0: top_n >= 0)
18
+ @pre(lambda perception_map, top_n=0: isinstance(perception_map, PerceptionMap) and top_n >= 0)
19
19
  def format_map_text(perception_map: PerceptionMap, top_n: int = 0) -> str:
20
20
  """
21
21
  Format perception map as plain text.
@@ -137,7 +137,7 @@ def _symbol_refs_to_dict(sr: SymbolRefs) -> dict:
137
137
  }
138
138
 
139
139
 
140
- @pre(lambda symbol, file_path: len(file_path) > 0)
140
+ @pre(lambda symbol, file_path: isinstance(symbol, Symbol) and len(file_path) > 0)
141
141
  def format_signature(symbol: Symbol, file_path: str) -> str:
142
142
  """
143
143
  Format a single symbol signature.
@@ -153,7 +153,7 @@ def format_signature(symbol: Symbol, file_path: str) -> str:
153
153
  return f"{file_path}::{symbol.name}{sig}"
154
154
 
155
155
 
156
- @pre(lambda symbols, file_path: len(file_path) > 0)
156
+ @pre(lambda symbols, file_path: isinstance(symbols, list) and len(file_path) > 0)
157
157
  def format_signatures_text(symbols: list[Symbol], file_path: str) -> str:
158
158
  """
159
159
  Format multiple signatures as text.
@@ -168,7 +168,7 @@ def format_signatures_text(symbols: list[Symbol], file_path: str) -> str:
168
168
  return "\n".join(lines)
169
169
 
170
170
 
171
- @pre(lambda symbols, file_path: len(file_path) > 0)
171
+ @pre(lambda symbols, file_path: isinstance(symbols, list) and len(file_path) > 0)
172
172
  def format_signatures_json(symbols: list[Symbol], file_path: str) -> dict:
173
173
  """
174
174
  Format signatures as JSON-serializable dict.
@@ -199,7 +199,10 @@ def format_signatures_json(symbols: list[Symbol], file_path: str) -> dict:
199
199
  # Phase 8.2: Agent-mode formatting
200
200
 
201
201
 
202
- @pre(lambda report, combined_status=None: combined_status is None or combined_status in ("passed", "failed"))
202
+ @pre(
203
+ lambda report, combined_status=None: isinstance(report, GuardReport)
204
+ and (combined_status is None or combined_status in ("passed", "failed"))
205
+ )
203
206
  def format_guard_agent(report: GuardReport, combined_status: str | None = None) -> dict:
204
207
  """
205
208
  Format Guard report for Agent consumption (Phase 8.2 + DX-26).
@@ -301,7 +304,7 @@ def _violation_to_fix(v: Violation) -> dict:
301
304
  return result
302
305
 
303
306
 
304
- @pre(lambda suggestion, rule: suggestion is None or isinstance(suggestion, str))
307
+ @pre(lambda suggestion, rule: (suggestion is None or isinstance(suggestion, str)) and len(rule) > 0)
305
308
  def _parse_suggestion(suggestion: str | None, rule: str) -> dict | None:
306
309
  """Parse suggestion string into structured fix instruction."""
307
310
  if not suggestion:
@@ -4,6 +4,7 @@ Hypothesis strategy generation from type annotations and @pre contracts.
4
4
  Core module: converts Python types and @pre bounds to Hypothesis strategies.
5
5
  Part of DX-12: Hypothesis as CrossHair fallback.
6
6
  """
7
+ # @invar:allow file_size: Strategy gen inherently complex, extraction increases coupling
7
8
 
8
9
  from __future__ import annotations
9
10
 
@@ -136,7 +137,9 @@ def _strategy_for_dict(args: tuple, strategy_fn: Callable) -> StrategySpec:
136
137
  def _strategy_for_set(args: tuple, strategy_fn: Callable) -> StrategySpec:
137
138
  """Generate strategy for set type."""
138
139
  element_type = args[0] if args else int
139
- return StrategySpec("frozensets", {"elements": strategy_fn(element_type).to_code()}, f"Sets of {element_type}")
140
+ return StrategySpec(
141
+ "frozensets", {"elements": strategy_fn(element_type).to_code()}, f"Sets of {element_type}"
142
+ )
140
143
 
141
144
 
142
145
  @post(lambda result: result is None or isinstance(result, StrategySpec))
@@ -149,7 +152,11 @@ def _strategy_for_numpy(hint: type) -> StrategySpec | None:
149
152
  if hint is np.ndarray or (hasattr(hint, "__name__") and "ndarray" in str(hint)):
150
153
  return StrategySpec(
151
154
  "arrays",
152
- {"dtype": "np.float64", "shape": "st.integers(1, 100)", "elements": "st.floats(-1e6, 1e6, allow_nan=False)"},
155
+ {
156
+ "dtype": "np.float64",
157
+ "shape": "st.integers(1, 100)",
158
+ "elements": "st.floats(-1e6, 1e6, allow_nan=False)",
159
+ },
153
160
  "NumPy float64 array",
154
161
  )
155
162
  return None
@@ -182,7 +189,9 @@ def strategy_from_type(hint: type) -> StrategySpec:
182
189
  if hint is list:
183
190
  return StrategySpec("lists", {"elements": "st.integers()"}, "Lists of int")
184
191
  if hint is dict:
185
- return StrategySpec("dictionaries", {"keys": "st.text()", "values": "st.integers()"}, "Dict")
192
+ return StrategySpec(
193
+ "dictionaries", {"keys": "st.text()", "values": "st.integers()"}, "Dict"
194
+ )
186
195
  if hint is tuple:
187
196
  return StrategySpec("tuples", {}, "Tuple")
188
197
  if hint is set:
@@ -310,7 +319,12 @@ def infer_strategies_for_function(func: Callable) -> dict[str, StrategySpec]:
310
319
  return result
311
320
 
312
321
 
313
- @pre(lambda func, type_specs, user_strategies, pre_sources: callable(func))
322
+ @pre(
323
+ lambda func, type_specs, user_strategies, pre_sources: callable(func)
324
+ and isinstance(type_specs, dict)
325
+ and isinstance(user_strategies, dict)
326
+ and isinstance(pre_sources, list)
327
+ )
314
328
  @post(lambda result: isinstance(result, dict))
315
329
  def _refine_all_strategies(
316
330
  func: Callable,
@@ -467,7 +481,12 @@ def _extract_pre_sources(func: Callable) -> list[str]:
467
481
  return pre_sources
468
482
 
469
483
 
470
- @post(lambda result: all(k in ("min_value", "max_value", "min_size", "max_size", "exclude_min", "exclude_max") for k in result))
484
+ @post(
485
+ lambda result: all(
486
+ k in ("min_value", "max_value", "min_size", "max_size", "exclude_min", "exclude_max")
487
+ for k in result
488
+ )
489
+ )
471
490
  def _bounds_to_strategy_kwargs(bounds: dict[str, Any], strategy_name: str) -> dict[str, Any]:
472
491
  """Convert bound constraints to Hypothesis strategy kwargs."""
473
492
  kwargs = {}
@@ -8,7 +8,7 @@ import re
8
8
  from deal import post, pre
9
9
 
10
10
 
11
- @pre(lambda tree: isinstance(tree, ast.AST) and hasattr(tree, '__class__'))
11
+ @pre(lambda tree: isinstance(tree, ast.AST) and hasattr(tree, "__class__"))
12
12
  @post(lambda result: result is None or isinstance(result, ast.Lambda))
13
13
  def find_lambda(tree: ast.Expression) -> ast.Lambda | None:
14
14
  """Find the lambda node in an expression tree.
@@ -114,7 +114,7 @@ def extract_func_param_names(signature: str) -> list[str] | None:
114
114
  return params
115
115
 
116
116
 
117
- @pre(lambda node: isinstance(node, ast.expr) and hasattr(node, '__class__'))
117
+ @pre(lambda node: isinstance(node, ast.expr) and hasattr(node, "__class__"))
118
118
  @post(lambda result: isinstance(result, set))
119
119
  def extract_used_names(node: ast.expr) -> set[str]:
120
120
  """Extract all variable names used in an expression (Load context).