invar-tools 1.0.0__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- invar/__init__.py +1 -0
- invar/core/contracts.py +80 -10
- invar/core/entry_points.py +367 -0
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +195 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +32 -10
- invar/core/hypothesis_strategies.py +50 -10
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -2
- invar/core/models.py +30 -18
- invar/core/must_use.py +2 -1
- invar/core/parser.py +13 -6
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +86 -42
- invar/core/purity.py +13 -7
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +370 -0
- invar/core/rule_meta.py +69 -2
- invar/core/rules.py +91 -28
- invar/core/shell_analysis.py +247 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +92 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +103 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +63 -18
- invar/core/verification_routing.py +155 -0
- invar/mcp/server.py +113 -13
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +152 -44
- invar/shell/{init_cmd.py → commands/init.py} +200 -28
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/mutate.py +184 -0
- invar/shell/{perception.py → commands/perception.py} +2 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/{test_cmd.py → commands/test.py} +3 -1
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +247 -10
- invar/shell/coverage.py +351 -0
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +116 -20
- invar/shell/guard_output.py +106 -24
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutation.py +314 -0
- invar/shell/property_tests.py +75 -24
- invar/shell/prove/__init__.py +9 -0
- invar/shell/prove/accept.py +113 -0
- invar/shell/{prove.py → prove/crosshair.py} +69 -30
- invar/shell/prove/hypothesis.py +293 -0
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +53 -0
- invar/shell/testing.py +77 -37
- invar/templates/CLAUDE.md.template +86 -9
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/audit.md +138 -0
- invar/templates/commands/guard.md +77 -0
- invar/templates/config/CLAUDE.md.jinja +206 -0
- invar/templates/config/context.md.jinja +92 -0
- invar/templates/config/pre-commit.yaml.jinja +44 -0
- invar/templates/context.md.template +33 -0
- invar/templates/cursorrules.template +25 -13
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +4 -2
- invar/templates/examples/core_shell.py +10 -4
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/protocol/INVAR.md +210 -0
- invar/templates/skills/develop/SKILL.md.jinja +318 -0
- invar/templates/skills/investigate/SKILL.md.jinja +106 -0
- invar/templates/skills/propose/SKILL.md.jinja +104 -0
- invar/templates/skills/review/SKILL.md.jinja +125 -0
- invar_tools-1.3.0.dist-info/METADATA +377 -0
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -57
- invar/resource.py +0 -99
- invar/shell/prove_fallback.py +0 -183
- invar/shell/update_cmd.py +0 -191
- invar/templates/INVAR.md +0 -134
- invar_tools-1.0.0.dist-info/METADATA +0 -321
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
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
|
|
|
@@ -94,7 +94,7 @@ class GuardReport(BaseModel):
|
|
|
94
94
|
core_functions_total: int = 0
|
|
95
95
|
core_functions_with_contracts: int = 0
|
|
96
96
|
|
|
97
|
-
@pre(lambda self, violation:
|
|
97
|
+
@pre(lambda self, violation: violation.rule and violation.severity) # Valid violation
|
|
98
98
|
def add_violation(self, violation: Violation) -> None:
|
|
99
99
|
"""
|
|
100
100
|
Add a violation and update counts.
|
|
@@ -133,7 +133,7 @@ class GuardReport(BaseModel):
|
|
|
133
133
|
self.core_functions_with_contracts += with_contracts
|
|
134
134
|
|
|
135
135
|
@property
|
|
136
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
|
|
224
|
-
|
|
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,22 +244,25 @@ 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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
|
254
259
|
|
|
260
|
+
# Timeout configuration (seconds) - MAJOR-3 fix
|
|
261
|
+
timeout_doctest: int = Field(default=60, ge=1, le=600) # Doctests should be fast
|
|
262
|
+
timeout_hypothesis: int = Field(default=300, ge=1, le=1800) # Property tests
|
|
263
|
+
timeout_crosshair: int = Field(default=300, ge=1, le=1800) # Symbolic verification total
|
|
264
|
+
timeout_crosshair_per_condition: int = Field(default=30, ge=1, le=300) # Per-contract limit
|
|
265
|
+
|
|
255
266
|
|
|
256
267
|
# Phase 4: Perception models
|
|
257
268
|
|
|
@@ -283,7 +294,8 @@ class PerceptionMap(BaseModel):
|
|
|
283
294
|
5
|
|
284
295
|
"""
|
|
285
296
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
297
|
+
# MINOR-7: Added field validators
|
|
298
|
+
project_root: str = Field(min_length=1)
|
|
299
|
+
total_files: int = Field(ge=0)
|
|
300
|
+
total_symbols: int = Field(ge=0)
|
|
289
301
|
symbols: list[SymbolRefs] = Field(default_factory=list)
|
invar/core/must_use.py
CHANGED
|
@@ -58,6 +58,7 @@ def find_must_use_functions(source: str) -> dict[str, str]:
|
|
|
58
58
|
return must_use_funcs
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
@pre(lambda decorator: isinstance(decorator, ast.expr) and hasattr(decorator, '__class__'))
|
|
61
62
|
@post(lambda result: result is None or isinstance(result, str))
|
|
62
63
|
def _extract_must_use_reason(decorator: ast.expr) -> str | None:
|
|
63
64
|
"""Extract reason from @must_use decorator, or None if not a must_use."""
|
|
@@ -136,7 +137,7 @@ def _get_call_name(call: ast.Call) -> str | None:
|
|
|
136
137
|
return None
|
|
137
138
|
|
|
138
139
|
|
|
139
|
-
@
|
|
140
|
+
@post(lambda result: all(v.rule == "must_use_ignored" for v in result)) # Rule consistency
|
|
140
141
|
def check_must_use(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
141
142
|
"""
|
|
142
143
|
Check for ignored return values of @must_use functions.
|
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)
|
|
@@ -60,7 +64,7 @@ def parse_source(source: str, path: str = "<string>") -> FileInfo | None:
|
|
|
60
64
|
)
|
|
61
65
|
|
|
62
66
|
|
|
63
|
-
@pre(lambda tree: isinstance(tree, ast.Module))
|
|
67
|
+
@pre(lambda tree: isinstance(tree, ast.Module) and hasattr(tree, 'body'))
|
|
64
68
|
def _extract_symbols(tree: ast.Module) -> list[Symbol]:
|
|
65
69
|
"""
|
|
66
70
|
Extract function, class, and method symbols from AST.
|
|
@@ -186,7 +190,10 @@ def _parse_class(node: ast.ClassDef) -> Symbol:
|
|
|
186
190
|
)
|
|
187
191
|
|
|
188
192
|
|
|
189
|
-
@pre(lambda node:
|
|
193
|
+
@pre(lambda node: (
|
|
194
|
+
isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and
|
|
195
|
+
hasattr(node, 'decorator_list')
|
|
196
|
+
))
|
|
190
197
|
@post(lambda result: all(c.kind in ("pre", "post") for c in result))
|
|
191
198
|
def _extract_contracts(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[Contract]:
|
|
192
199
|
"""Extract @pre and @post contracts from function decorators."""
|
|
@@ -226,7 +233,7 @@ def _parse_decorator_as_contract(decorator: ast.expr) -> Contract | None:
|
|
|
226
233
|
return None
|
|
227
234
|
|
|
228
235
|
|
|
229
|
-
@pre(lambda call: isinstance(call, ast.Call))
|
|
236
|
+
@pre(lambda call: isinstance(call, ast.Call) and hasattr(call, 'args'))
|
|
230
237
|
def _get_contract_expression(call: ast.Call) -> str:
|
|
231
238
|
"""Extract the expression string from a contract decorator call."""
|
|
232
239
|
if call.args:
|
|
@@ -260,7 +267,7 @@ def _build_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
|
260
267
|
return sig
|
|
261
268
|
|
|
262
269
|
|
|
263
|
-
@pre(lambda tree: isinstance(tree, ast.Module))
|
|
270
|
+
@pre(lambda tree: isinstance(tree, ast.Module) and hasattr(tree, 'body'))
|
|
264
271
|
@post(lambda result: all(isinstance(s, str) and s for s in result))
|
|
265
272
|
def _extract_imports(tree: ast.Module) -> list[str]:
|
|
266
273
|
"""Extract imported module names from AST (top-level only)."""
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Postcondition scope validation for Guard. No I/O operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
from deal import post, pre
|
|
8
|
+
|
|
9
|
+
from invar.core.lambda_helpers import (
|
|
10
|
+
extract_func_param_names,
|
|
11
|
+
extract_used_names,
|
|
12
|
+
find_lambda,
|
|
13
|
+
)
|
|
14
|
+
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pre(lambda node: isinstance(node, ast.expr))
|
|
18
|
+
@post(lambda result: isinstance(result, set))
|
|
19
|
+
def _extract_comprehension_bound_names(node: ast.expr) -> set[str]:
|
|
20
|
+
"""Extract names bound by comprehensions (generator expressions, list comps, etc.).
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
>>> import ast
|
|
24
|
+
>>> expr = ast.parse("all(v.rule for v in result)", mode="eval").body
|
|
25
|
+
>>> sorted(_extract_comprehension_bound_names(expr))
|
|
26
|
+
['v']
|
|
27
|
+
"""
|
|
28
|
+
bound: set[str] = set()
|
|
29
|
+
for child in ast.walk(node):
|
|
30
|
+
if isinstance(child, ast.comprehension):
|
|
31
|
+
# Extract bound variable(s) from the target
|
|
32
|
+
for name in ast.walk(child.target):
|
|
33
|
+
if isinstance(name, ast.Name):
|
|
34
|
+
bound.add(name.id)
|
|
35
|
+
return bound
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@post(lambda result: isinstance(result, set))
|
|
39
|
+
def _extract_used_names_from_expression(expression: str) -> set[str]:
|
|
40
|
+
"""Extract free variable names from a lambda expression string.
|
|
41
|
+
|
|
42
|
+
Excludes comprehension-bound variables like 'v' in 'all(v.x for v in result)'.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
>>> sorted(_extract_used_names_from_expression("lambda result: len(result) > 0"))
|
|
46
|
+
['len', 'result']
|
|
47
|
+
>>> _extract_used_names_from_expression("lambda x: x > 0")
|
|
48
|
+
{'x'}
|
|
49
|
+
>>> _extract_used_names_from_expression("not a lambda")
|
|
50
|
+
set()
|
|
51
|
+
>>> sorted(_extract_used_names_from_expression("lambda result: all(v.rule for v in result)"))
|
|
52
|
+
['all', 'result']
|
|
53
|
+
"""
|
|
54
|
+
if not expression.strip() or "lambda" not in expression:
|
|
55
|
+
return set()
|
|
56
|
+
try:
|
|
57
|
+
tree = ast.parse(expression, mode="eval")
|
|
58
|
+
lambda_node = find_lambda(tree)
|
|
59
|
+
if lambda_node is None:
|
|
60
|
+
return set()
|
|
61
|
+
# Extract all names from lambda body
|
|
62
|
+
all_names = extract_used_names(lambda_node.body)
|
|
63
|
+
# Subtract comprehension-bound names
|
|
64
|
+
bound_names = _extract_comprehension_bound_names(lambda_node.body)
|
|
65
|
+
return all_names - bound_names
|
|
66
|
+
except (SyntaxError, TypeError, ValueError):
|
|
67
|
+
return set()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@post(lambda result: all(v.rule == "postcondition_scope_error" for v in result))
|
|
71
|
+
def check_postcondition_scope(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
72
|
+
"""Check @post lambdas for references to function parameters.
|
|
73
|
+
|
|
74
|
+
@post lambdas cannot access function parameters (except via 'result').
|
|
75
|
+
They can access module-level imports and builtins via closure.
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, Contract, RuleConfig
|
|
79
|
+
>>> c = Contract(kind="post", expression="lambda result: result > x", line=5)
|
|
80
|
+
>>> s = Symbol(name="f", kind=SymbolKind.FUNCTION, line=1, end_line=10,
|
|
81
|
+
... signature="(x: int) -> int", contracts=[c])
|
|
82
|
+
>>> info = FileInfo(path="test.py", lines=10, symbols=[s], is_core=True)
|
|
83
|
+
>>> vs = check_postcondition_scope(info, RuleConfig())
|
|
84
|
+
>>> len(vs) == 1 and "x" in vs[0].message
|
|
85
|
+
True
|
|
86
|
+
>>> # Module imports are allowed
|
|
87
|
+
>>> c2 = Contract(kind="post", expression="lambda result: isinstance(result, Violation)", line=5)
|
|
88
|
+
>>> s2 = Symbol(name="g", kind=SymbolKind.FUNCTION, line=1, end_line=10,
|
|
89
|
+
... signature="(data: str) -> Violation", contracts=[c2])
|
|
90
|
+
>>> info2 = FileInfo(path="test.py", lines=10, symbols=[s2], is_core=True)
|
|
91
|
+
>>> check_postcondition_scope(info2, RuleConfig())
|
|
92
|
+
[]
|
|
93
|
+
"""
|
|
94
|
+
violations: list[Violation] = []
|
|
95
|
+
if not file_info.is_core:
|
|
96
|
+
return violations
|
|
97
|
+
|
|
98
|
+
for symbol in file_info.symbols:
|
|
99
|
+
if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Extract function parameter names from signature
|
|
103
|
+
param_names = extract_func_param_names(symbol.signature) or []
|
|
104
|
+
# Exclude 'self', 'cls', and 'result' (valid in @post)
|
|
105
|
+
invalid_params = set(param_names) - {"self", "cls", "result"}
|
|
106
|
+
|
|
107
|
+
for contract in symbol.contracts:
|
|
108
|
+
if contract.kind != "post":
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
used_names = _extract_used_names_from_expression(contract.expression)
|
|
112
|
+
# Check if any function parameters are used (they shouldn't be)
|
|
113
|
+
param_references = used_names & invalid_params
|
|
114
|
+
|
|
115
|
+
if param_references:
|
|
116
|
+
kind = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
117
|
+
violations.append(
|
|
118
|
+
Violation(
|
|
119
|
+
rule="postcondition_scope_error",
|
|
120
|
+
severity=Severity.ERROR,
|
|
121
|
+
file=file_info.path,
|
|
122
|
+
line=contract.line,
|
|
123
|
+
message=f"{kind} '{symbol.name}' @post references function parameters: {', '.join(sorted(param_references))}",
|
|
124
|
+
suggestion="@post lambdas can only use 'result', not function parameters",
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return violations
|
invar/core/property_gen.py
CHANGED
|
@@ -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
|
|
@@ -140,7 +145,7 @@ def generate_property_test(func: Callable) -> GeneratedTest | None:
|
|
|
140
145
|
)
|
|
141
146
|
|
|
142
147
|
|
|
143
|
-
@pre(lambda func_name, strategies:
|
|
148
|
+
@pre(lambda func_name, strategies: len(func_name) > 0 and len(strategies) > 0)
|
|
144
149
|
@post(lambda result: isinstance(result, str) and "@given" in result)
|
|
145
150
|
def _generate_test_code(func_name: str, strategies: dict[str, str]) -> str:
|
|
146
151
|
"""
|
|
@@ -189,7 +194,10 @@ def _check_decorator_contracts(dec: ast.Call) -> tuple[bool, bool]:
|
|
|
189
194
|
return has_pre, has_post
|
|
190
195
|
|
|
191
196
|
|
|
192
|
-
@pre(lambda node:
|
|
197
|
+
@pre(lambda node: (
|
|
198
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and
|
|
199
|
+
hasattr(node, 'decorator_list')
|
|
200
|
+
))
|
|
193
201
|
@post(lambda result: isinstance(result, tuple) and len(result) == 2)
|
|
194
202
|
def _get_function_contracts(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[bool, bool]:
|
|
195
203
|
"""Check function decorators for contracts, return (has_pre, has_post).
|
|
@@ -300,6 +308,10 @@ def build_test_function(
|
|
|
300
308
|
# Build strategy dict
|
|
301
309
|
strategy_dict = {}
|
|
302
310
|
for param_name, strat_code in strategies.items():
|
|
311
|
+
# Skip functions with nothing() strategy (untestable types)
|
|
312
|
+
if "nothing()" in strat_code:
|
|
313
|
+
return None
|
|
314
|
+
|
|
303
315
|
# Evaluate the strategy code
|
|
304
316
|
try:
|
|
305
317
|
# strat_code is like "st.integers(min_value=0)"
|
|
@@ -322,16 +334,62 @@ def build_test_function(
|
|
|
322
334
|
return property_test
|
|
323
335
|
|
|
324
336
|
|
|
325
|
-
@pre(lambda
|
|
337
|
+
@pre(lambda error_str: len(error_str) > 0)
|
|
338
|
+
@post(lambda result: result is None or isinstance(result, int))
|
|
339
|
+
def _extract_hypothesis_seed(error_str: str) -> int | None:
|
|
340
|
+
"""Extract Hypothesis seed from error message (DX-26).
|
|
341
|
+
|
|
342
|
+
Hypothesis includes seed in output like: @seed(336048909179393285647920446708996038674)
|
|
343
|
+
|
|
344
|
+
>>> _extract_hypothesis_seed("@seed(123456)")
|
|
345
|
+
123456
|
|
346
|
+
>>> _extract_hypothesis_seed("no seed here") is None
|
|
347
|
+
True
|
|
348
|
+
"""
|
|
349
|
+
import re
|
|
350
|
+
|
|
351
|
+
match = re.search(r"@seed\((\d+)\)", error_str)
|
|
352
|
+
if match:
|
|
353
|
+
try:
|
|
354
|
+
return int(match.group(1))
|
|
355
|
+
except ValueError:
|
|
356
|
+
pass
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@pre(lambda name, reason: len(name) > 0 and len(reason) > 0)
|
|
361
|
+
@post(lambda result: isinstance(result, PropertyTestResult) and result.passed)
|
|
362
|
+
def _skip_result(name: str, reason: str) -> PropertyTestResult:
|
|
363
|
+
"""Create a skip result (passed=True, 0 examples)."""
|
|
364
|
+
return PropertyTestResult(function_name=name, passed=True, examples_run=0, error=reason)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# Skip patterns for untestable error detection
|
|
368
|
+
_SKIP_PATTERNS = (
|
|
369
|
+
"Nothing", "NoSuchExample", "filter_too_much", "Could not resolve",
|
|
370
|
+
"validation error", "missing", "positional argument", "Unable to satisfy",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@pre(lambda err_str, func_name, max_examples: len(err_str) > 0 and len(func_name) > 0 and max_examples > 0)
|
|
326
375
|
@post(lambda result: isinstance(result, PropertyTestResult))
|
|
327
|
-
def
|
|
328
|
-
|
|
329
|
-
max_examples: int = 100,
|
|
376
|
+
def _handle_test_exception(
|
|
377
|
+
err_str: str, func_name: str, max_examples: int
|
|
330
378
|
) -> PropertyTestResult:
|
|
379
|
+
"""Handle exception from property test, returning skip or failure result."""
|
|
380
|
+
if any(p in err_str for p in _SKIP_PATTERNS):
|
|
381
|
+
return _skip_result(func_name, "Skipped: untestable types")
|
|
382
|
+
seed = _extract_hypothesis_seed(err_str)
|
|
383
|
+
return PropertyTestResult(func_name, passed=False, examples_run=max_examples, error=err_str, seed=seed)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@pre(lambda func, max_examples: callable(func) and max_examples > 0)
|
|
387
|
+
@post(lambda result: isinstance(result, PropertyTestResult))
|
|
388
|
+
def run_property_test(func: Callable, max_examples: int = 100) -> PropertyTestResult:
|
|
331
389
|
"""
|
|
332
390
|
Run a property test on a single function.
|
|
333
391
|
|
|
334
|
-
|
|
392
|
+
Uses deal.cases() which respects @pre conditions and generates valid inputs.
|
|
335
393
|
|
|
336
394
|
>>> from deal import pre, post
|
|
337
395
|
>>> @pre(lambda x: x >= 0)
|
|
@@ -344,40 +402,26 @@ def run_property_test(
|
|
|
344
402
|
"""
|
|
345
403
|
func_name = getattr(func, "__name__", "unknown")
|
|
346
404
|
|
|
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
405
|
try:
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
406
|
+
import deal
|
|
407
|
+
from hypothesis import HealthCheck, Verbosity, settings
|
|
408
|
+
|
|
409
|
+
# DX-26: Suppress Hypothesis output (seed messages) for clean JSON
|
|
410
|
+
test_settings = settings(
|
|
411
|
+
max_examples=max_examples,
|
|
412
|
+
suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow],
|
|
413
|
+
verbosity=Verbosity.quiet,
|
|
374
414
|
)
|
|
415
|
+
test_case = deal.cases(func, count=max_examples, settings=test_settings)
|
|
416
|
+
test_case()
|
|
417
|
+
return PropertyTestResult(func_name, passed=True, examples_run=max_examples)
|
|
418
|
+
except deal.PreContractError:
|
|
419
|
+
return _skip_result(func_name, "Skipped: could not generate valid inputs")
|
|
420
|
+
except deal.PostContractError as e:
|
|
421
|
+
err_str = str(e)
|
|
422
|
+
seed = _extract_hypothesis_seed(err_str)
|
|
423
|
+
return PropertyTestResult(func_name, passed=False, examples_run=max_examples, error=err_str, seed=seed)
|
|
424
|
+
except ImportError:
|
|
425
|
+
pass # Fall through to custom strategy approach
|
|
375
426
|
except Exception as e:
|
|
376
|
-
|
|
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
|
-
)
|
|
427
|
+
return _handle_test_exception(str(e), func_name, max_examples)
|
invar/core/purity.py
CHANGED
|
@@ -13,16 +13,18 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import ast
|
|
15
15
|
|
|
16
|
-
from deal import pre
|
|
16
|
+
from deal import post, pre
|
|
17
17
|
|
|
18
18
|
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
19
19
|
|
|
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
|
-
|
|
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
|
|
@@ -278,7 +284,7 @@ def count_doctest_lines(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
|
278
284
|
# Rule checking functions
|
|
279
285
|
|
|
280
286
|
|
|
281
|
-
@
|
|
287
|
+
@post(lambda result: all(v.rule == "internal_import" for v in result)) # Rule consistency
|
|
282
288
|
def check_internal_imports(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
283
289
|
"""
|
|
284
290
|
Check for imports inside function bodies.
|
|
@@ -318,7 +324,7 @@ def check_internal_imports(file_info: FileInfo, config: RuleConfig) -> list[Viol
|
|
|
318
324
|
return violations
|
|
319
325
|
|
|
320
326
|
|
|
321
|
-
@
|
|
327
|
+
@post(lambda result: all(v.rule == "impure_call" for v in result)) # Rule consistency
|
|
322
328
|
def check_impure_calls(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
323
329
|
"""
|
|
324
330
|
Check for calls to known impure functions.
|
invar/core/purity_heuristics.py
CHANGED
|
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import re
|
|
11
11
|
from dataclasses import dataclass
|
|
12
12
|
|
|
13
|
-
from deal import post
|
|
13
|
+
from deal import post
|
|
14
14
|
|
|
15
15
|
# Name patterns suggesting impurity
|
|
16
16
|
IMPURE_NAME_PATTERNS = [
|
|
@@ -75,8 +75,7 @@ class HeuristicResult:
|
|
|
75
75
|
hints: list[str]
|
|
76
76
|
|
|
77
77
|
|
|
78
|
-
@
|
|
79
|
-
@post(lambda result: isinstance(result, tuple) and len(result) == 2)
|
|
78
|
+
@post(lambda result: len(result) == 2 and all(x >= 0 for x in result)) # Scores are non-negative
|
|
80
79
|
def _analyze_name_patterns(func_name: str, hints: list[str]) -> tuple[int, int]:
|
|
81
80
|
"""Analyze function name for purity hints. Returns (impure_score, pure_score).
|
|
82
81
|
|
|
@@ -98,8 +97,7 @@ def _analyze_name_patterns(func_name: str, hints: list[str]) -> tuple[int, int]:
|
|
|
98
97
|
return impure, pure
|
|
99
98
|
|
|
100
99
|
|
|
101
|
-
@
|
|
102
|
-
@post(lambda result: isinstance(result, tuple) and len(result) == 2)
|
|
100
|
+
@post(lambda result: len(result) == 2 and all(x >= 0 for x in result)) # Scores are non-negative
|
|
103
101
|
def _analyze_signature(signature: str | None, hints: list[str]) -> tuple[int, int]:
|
|
104
102
|
"""Analyze signature for purity hints. Returns (impure_score, pure_score).
|
|
105
103
|
|
|
@@ -122,8 +120,7 @@ def _analyze_signature(signature: str | None, hints: list[str]) -> tuple[int, in
|
|
|
122
120
|
return impure, pure
|
|
123
121
|
|
|
124
122
|
|
|
125
|
-
@
|
|
126
|
-
@post(lambda result: isinstance(result, int) and result >= 0)
|
|
123
|
+
@post(lambda result: result >= 0) # Impure score is non-negative
|
|
127
124
|
def _analyze_docstring(docstring: str | None, hints: list[str]) -> int:
|
|
128
125
|
"""Analyze docstring for purity hints. Returns impure_score.
|
|
129
126
|
|
|
@@ -141,8 +138,7 @@ def _analyze_docstring(docstring: str | None, hints: list[str]) -> int:
|
|
|
141
138
|
return 0
|
|
142
139
|
|
|
143
140
|
|
|
144
|
-
@
|
|
145
|
-
@post(lambda result: isinstance(result, HeuristicResult))
|
|
141
|
+
@post(lambda result: 0.0 <= result.confidence <= 1.0) # Confidence in [0, 1]
|
|
146
142
|
def analyze_purity_heuristic(
|
|
147
143
|
func_name: str,
|
|
148
144
|
signature: str | None = None,
|
invar/core/references.py
CHANGED
|
@@ -17,8 +17,8 @@ from deal import post, pre
|
|
|
17
17
|
from invar.core.models import FileInfo, PerceptionMap, SymbolKind, SymbolRefs
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
@pre(lambda source, known_symbols:
|
|
21
|
-
@post(lambda result: isinstance(
|
|
20
|
+
@pre(lambda source, known_symbols: len(source) > 0 and len(known_symbols) > 0) # Non-empty inputs
|
|
21
|
+
@post(lambda result: all(isinstance(name, str) and line > 0 for name, line in result)) # Valid refs
|
|
22
22
|
def find_references_in_source(source: str, known_symbols: set[str]) -> list[tuple[str, int]]:
|
|
23
23
|
"""
|
|
24
24
|
Find references to known symbols in source code.
|
|
@@ -51,8 +51,7 @@ def find_references_in_source(source: str, known_symbols: set[str]) -> list[tupl
|
|
|
51
51
|
return list(seen)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
@
|
|
55
|
-
@post(lambda result: isinstance(result, dict))
|
|
54
|
+
@post(lambda result: all(isinstance(v, str) for v in result.values()))
|
|
56
55
|
def build_symbol_table(file_infos: list[FileInfo]) -> dict[str, str]:
|
|
57
56
|
"""
|
|
58
57
|
Build a mapping of symbol names to their defining file.
|
|
@@ -78,7 +77,7 @@ def build_symbol_table(file_infos: list[FileInfo]) -> dict[str, str]:
|
|
|
78
77
|
return symbol_table
|
|
79
78
|
|
|
80
79
|
|
|
81
|
-
@
|
|
80
|
+
@post(lambda result: all("::" in k and v >= 0 for k, v in result.items())) # Valid ref counts
|
|
82
81
|
def count_cross_file_references(
|
|
83
82
|
file_infos: list[FileInfo], sources: dict[str, str]
|
|
84
83
|
) -> dict[str, int]:
|
|
@@ -124,6 +123,8 @@ def count_cross_file_references(
|
|
|
124
123
|
|
|
125
124
|
@pre(lambda file_infos, sources, project_root: (
|
|
126
125
|
isinstance(file_infos, list) and
|
|
126
|
+
all(isinstance(fi, FileInfo) for fi in file_infos) and
|
|
127
|
+
isinstance(sources, dict) and
|
|
127
128
|
isinstance(project_root, str) and len(project_root) > 0
|
|
128
129
|
))
|
|
129
130
|
def build_perception_map(
|
|
@@ -172,8 +173,9 @@ def build_perception_map(
|
|
|
172
173
|
)
|
|
173
174
|
except Exception:
|
|
174
175
|
# Handle CrossHair symbolic value validation failures
|
|
176
|
+
# Use safe literal value that always passes validation
|
|
175
177
|
return PerceptionMap(
|
|
176
|
-
project_root="",
|
|
178
|
+
project_root="/",
|
|
177
179
|
total_files=0,
|
|
178
180
|
total_symbols=0,
|
|
179
181
|
symbols=[],
|