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.
- invar/core/contracts.py +38 -6
- invar/core/doc_edit.py +22 -9
- invar/core/doc_parser.py +17 -11
- invar/core/entry_points.py +48 -42
- invar/core/extraction.py +25 -9
- invar/core/format_specs.py +7 -2
- invar/core/format_strategies.py +6 -4
- invar/core/formatter.py +9 -6
- invar/core/hypothesis_strategies.py +24 -5
- invar/core/lambda_helpers.py +2 -2
- invar/core/parser.py +40 -21
- invar/core/patterns/detector.py +25 -6
- invar/core/patterns/p0_exhaustive.py +5 -5
- invar/core/patterns/p0_literal.py +8 -8
- invar/core/patterns/p0_newtype.py +2 -2
- invar/core/patterns/p0_nonempty.py +12 -7
- invar/core/patterns/p0_validation.py +4 -8
- invar/core/patterns/registry.py +12 -2
- invar/core/property_gen.py +47 -23
- invar/core/shell_analysis.py +70 -66
- invar/core/strategies.py +14 -3
- invar/core/suggestions.py +12 -4
- invar/core/tautology.py +33 -10
- invar/core/template_parser.py +23 -15
- invar/core/ts_parsers.py +6 -2
- invar/core/ts_sig_parser.py +18 -10
- invar/core/utils.py +20 -9
- invar/templates/protocol/python/tools.md +3 -0
- {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/METADATA +1 -1
- {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/RECORD +35 -35
- {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/WHEEL +0 -0
- {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.17.26.dist-info → invar_tools-1.17.27.dist-info}/licenses/LICENSE-GPL +0 -0
- {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,
|
|
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,
|
|
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,
|
|
91
|
-
valid_calls = [
|
|
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,
|
|
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
|
|
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(
|
|
19
|
-
|
|
20
|
-
|
|
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(
|
|
77
|
-
|
|
78
|
-
|
|
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(
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
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 ==
|
|
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 =
|
|
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
|
|
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(
|
|
519
|
-
|
|
520
|
-
|
|
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
|
|
invar/core/entry_points.py
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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(
|
|
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(
|
|
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.
|
invar/core/format_specs.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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.
|
invar/core/format_strategies.py
CHANGED
|
@@ -112,7 +112,11 @@ def crosshair_output(
|
|
|
112
112
|
)
|
|
113
113
|
|
|
114
114
|
|
|
115
|
-
@pre(
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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(
|
|
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(
|
|
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(
|
|
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 = {}
|
invar/core/lambda_helpers.py
CHANGED
|
@@ -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,
|
|
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,
|
|
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).
|