invar-tools 1.2.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 +10 -10
- invar/core/entry_points.py +105 -32
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +1 -2
- invar/core/formatter.py +6 -7
- invar/core/hypothesis_strategies.py +5 -7
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -3
- invar/core/models.py +7 -1
- invar/core/must_use.py +2 -1
- invar/core/parser.py +7 -4
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +8 -5
- invar/core/purity.py +3 -3
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +78 -6
- invar/core/rule_meta.py +8 -0
- invar/core/rules.py +18 -19
- invar/core/shell_analysis.py +5 -10
- invar/core/shell_architecture.py +2 -2
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +86 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +102 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +13 -15
- invar/core/verification_routing.py +4 -7
- invar/mcp/server.py +100 -17
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +94 -14
- invar/shell/{init_cmd.py → commands/init.py} +179 -27
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +12 -24
- invar/shell/coverage.py +351 -0
- invar/shell/guard_helpers.py +38 -17
- invar/shell/guard_output.py +7 -1
- invar/shell/property_tests.py +58 -22
- invar/shell/prove/__init__.py +9 -0
- invar/shell/{prove.py → prove/crosshair.py} +40 -33
- invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +19 -0
- invar/shell/testing.py +71 -20
- invar/templates/CLAUDE.md.template +38 -17
- invar/templates/aider.conf.yml.template +2 -2
- invar/templates/commands/{review.md → audit.md} +20 -82
- 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 +7 -4
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +5 -5
- invar/templates/examples/core_shell.py +11 -7
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
- 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.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -58
- invar/resource.py +0 -99
- invar/shell/update_cmd.py +0 -193
- invar_tools-1.2.0.dist-info/RECORD +0 -77
- invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
- /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
- /invar/shell/{perception.py → commands/perception.py} +0 -0
- /invar/shell/{test_cmd.py → commands/test.py} +0 -0
- /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -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
|
@@ -145,7 +145,7 @@ def generate_property_test(func: Callable) -> GeneratedTest | None:
|
|
|
145
145
|
)
|
|
146
146
|
|
|
147
147
|
|
|
148
|
-
@pre(lambda func_name, strategies:
|
|
148
|
+
@pre(lambda func_name, strategies: len(func_name) > 0 and len(strategies) > 0)
|
|
149
149
|
@post(lambda result: isinstance(result, str) and "@given" in result)
|
|
150
150
|
def _generate_test_code(func_name: str, strategies: dict[str, str]) -> str:
|
|
151
151
|
"""
|
|
@@ -194,7 +194,10 @@ def _check_decorator_contracts(dec: ast.Call) -> tuple[bool, bool]:
|
|
|
194
194
|
return has_pre, has_post
|
|
195
195
|
|
|
196
196
|
|
|
197
|
-
@pre(lambda node:
|
|
197
|
+
@pre(lambda node: (
|
|
198
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and
|
|
199
|
+
hasattr(node, 'decorator_list')
|
|
200
|
+
))
|
|
198
201
|
@post(lambda result: isinstance(result, tuple) and len(result) == 2)
|
|
199
202
|
def _get_function_contracts(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[bool, bool]:
|
|
200
203
|
"""Check function decorators for contracts, return (has_pre, has_post).
|
|
@@ -331,7 +334,7 @@ def build_test_function(
|
|
|
331
334
|
return property_test
|
|
332
335
|
|
|
333
336
|
|
|
334
|
-
@pre(lambda error_str:
|
|
337
|
+
@pre(lambda error_str: len(error_str) > 0)
|
|
335
338
|
@post(lambda result: result is None or isinstance(result, int))
|
|
336
339
|
def _extract_hypothesis_seed(error_str: str) -> int | None:
|
|
337
340
|
"""Extract Hypothesis seed from error message (DX-26).
|
|
@@ -354,7 +357,7 @@ def _extract_hypothesis_seed(error_str: str) -> int | None:
|
|
|
354
357
|
return None
|
|
355
358
|
|
|
356
359
|
|
|
357
|
-
@pre(lambda name, reason:
|
|
360
|
+
@pre(lambda name, reason: len(name) > 0 and len(reason) > 0)
|
|
358
361
|
@post(lambda result: isinstance(result, PropertyTestResult) and result.passed)
|
|
359
362
|
def _skip_result(name: str, reason: str) -> PropertyTestResult:
|
|
360
363
|
"""Create a skip result (passed=True, 0 examples)."""
|
|
@@ -368,7 +371,7 @@ _SKIP_PATTERNS = (
|
|
|
368
371
|
)
|
|
369
372
|
|
|
370
373
|
|
|
371
|
-
@pre(lambda err_str, func_name, max_examples:
|
|
374
|
+
@pre(lambda err_str, func_name, max_examples: len(err_str) > 0 and len(func_name) > 0 and max_examples > 0)
|
|
372
375
|
@post(lambda result: isinstance(result, PropertyTestResult))
|
|
373
376
|
def _handle_test_exception(
|
|
374
377
|
err_str: str, func_name: str, max_examples: int
|
invar/core/purity.py
CHANGED
|
@@ -13,7 +13,7 @@ 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
|
|
|
@@ -284,7 +284,7 @@ def count_doctest_lines(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
|
|
|
284
284
|
# Rule checking functions
|
|
285
285
|
|
|
286
286
|
|
|
287
|
-
@
|
|
287
|
+
@post(lambda result: all(v.rule == "internal_import" for v in result)) # Rule consistency
|
|
288
288
|
def check_internal_imports(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
289
289
|
"""
|
|
290
290
|
Check for imports inside function bodies.
|
|
@@ -324,7 +324,7 @@ def check_internal_imports(file_info: FileInfo, config: RuleConfig) -> list[Viol
|
|
|
324
324
|
return violations
|
|
325
325
|
|
|
326
326
|
|
|
327
|
-
@
|
|
327
|
+
@post(lambda result: all(v.rule == "impure_call" for v in result)) # Rule consistency
|
|
328
328
|
def check_impure_calls(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
329
329
|
"""
|
|
330
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=[],
|
invar/core/review_trigger.py
CHANGED
|
@@ -38,8 +38,7 @@ SECURITY_WORD_PATTERNS: tuple[str, ...] = (
|
|
|
38
38
|
)
|
|
39
39
|
|
|
40
40
|
|
|
41
|
-
@
|
|
42
|
-
@post(lambda result: isinstance(result, tuple) and len(result) == 3)
|
|
41
|
+
@post(lambda result: len(result) == 3 and 0.0 <= result[0] <= 1.0) # Ratio in [0, 1]
|
|
43
42
|
def calculate_contract_ratio(file_info: FileInfo) -> tuple[float, int, int]:
|
|
44
43
|
"""
|
|
45
44
|
Calculate contract coverage ratio for a file (DX-31).
|
|
@@ -88,7 +87,7 @@ def calculate_contract_ratio(file_info: FileInfo) -> tuple[float, int, int]:
|
|
|
88
87
|
return (ratio, total, with_contracts)
|
|
89
88
|
|
|
90
89
|
|
|
91
|
-
@
|
|
90
|
+
@post(lambda result: all(v.rule == "contract_quality_ratio" for v in result))
|
|
92
91
|
def check_contract_quality_ratio(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
93
92
|
"""
|
|
94
93
|
Check contract coverage ratio in Core files (DX-30).
|
|
@@ -141,8 +140,7 @@ def check_contract_quality_ratio(file_info: FileInfo, config: RuleConfig) -> lis
|
|
|
141
140
|
return violations
|
|
142
141
|
|
|
143
142
|
|
|
144
|
-
@
|
|
145
|
-
@post(lambda result: isinstance(result, bool))
|
|
143
|
+
# @invar:allow missing_contract: Boolean predicate, accepts empty string (doctest shows)
|
|
146
144
|
def is_security_sensitive(path: str) -> bool:
|
|
147
145
|
"""
|
|
148
146
|
Check if path indicates security-sensitive code (DX-31).
|
|
@@ -200,7 +198,7 @@ def is_security_sensitive(path: str) -> bool:
|
|
|
200
198
|
return any(word in SECURITY_WORD_PATTERNS for word in words)
|
|
201
199
|
|
|
202
200
|
|
|
203
|
-
@
|
|
201
|
+
@post(lambda result: all(v.rule == "review_suggested" for v in result))
|
|
204
202
|
def check_review_suggested(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
205
203
|
"""
|
|
206
204
|
Suggest independent review when conditions warrant (DX-31).
|
|
@@ -296,3 +294,77 @@ def check_review_suggested(file_info: FileInfo, config: RuleConfig) -> list[Viol
|
|
|
296
294
|
)
|
|
297
295
|
|
|
298
296
|
return violations
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@pre(lambda escapes: all(len(e) == 3 for e in escapes)) # Validate tuple structure
|
|
300
|
+
@post(lambda result: all(v.rule == "duplicate_escape_reason" for v in result))
|
|
301
|
+
def check_duplicate_escape_reasons(
|
|
302
|
+
escapes: list[tuple[str, str, str]],
|
|
303
|
+
) -> list[Violation]:
|
|
304
|
+
"""
|
|
305
|
+
Detect duplicate escape hatch reasons across files (DX-33 Option E).
|
|
306
|
+
|
|
307
|
+
Warns when 3+ files share identical escape reason text,
|
|
308
|
+
suggesting a systematic issue that should be fixed at the root.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
escapes: List of (file_path, rule, reason) tuples
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
List of violations for duplicate reasons
|
|
315
|
+
|
|
316
|
+
Examples:
|
|
317
|
+
>>> check_duplicate_escape_reasons([])
|
|
318
|
+
[]
|
|
319
|
+
>>> # 2 files with same reason - no warning (threshold is 3)
|
|
320
|
+
>>> escapes = [
|
|
321
|
+
... ("a.py", "rule", "same reason"),
|
|
322
|
+
... ("b.py", "rule", "same reason"),
|
|
323
|
+
... ]
|
|
324
|
+
>>> check_duplicate_escape_reasons(escapes)
|
|
325
|
+
[]
|
|
326
|
+
>>> # 3+ files with same reason - warning
|
|
327
|
+
>>> escapes = [
|
|
328
|
+
... ("a.py", "rule", "False positive - .get()"),
|
|
329
|
+
... ("b.py", "rule", "False positive - .get()"),
|
|
330
|
+
... ("c.py", "rule", "False positive - .get()"),
|
|
331
|
+
... ]
|
|
332
|
+
>>> vs = check_duplicate_escape_reasons(escapes)
|
|
333
|
+
>>> len(vs) == 1
|
|
334
|
+
True
|
|
335
|
+
>>> "3 files" in vs[0].message
|
|
336
|
+
True
|
|
337
|
+
>>> "False positive" in vs[0].message
|
|
338
|
+
True
|
|
339
|
+
"""
|
|
340
|
+
violations: list[Violation] = []
|
|
341
|
+
|
|
342
|
+
# Group by (reason) - normalize whitespace for comparison
|
|
343
|
+
reason_files: dict[str, list[str]] = {}
|
|
344
|
+
for file_path, _rule, reason in escapes:
|
|
345
|
+
normalized = reason.strip().lower()
|
|
346
|
+
if normalized not in reason_files:
|
|
347
|
+
reason_files[normalized] = []
|
|
348
|
+
reason_files[normalized].append(file_path)
|
|
349
|
+
|
|
350
|
+
# Check for duplicates (threshold: 3+ files)
|
|
351
|
+
for reason, files in reason_files.items():
|
|
352
|
+
if len(files) >= 3:
|
|
353
|
+
# Get original reason text from first occurrence
|
|
354
|
+
original_reason = next(
|
|
355
|
+
r for f, _, r in escapes if r.strip().lower() == reason
|
|
356
|
+
)
|
|
357
|
+
violations.append(
|
|
358
|
+
Violation(
|
|
359
|
+
rule="duplicate_escape_reason",
|
|
360
|
+
severity=Severity.WARNING,
|
|
361
|
+
file="<project>",
|
|
362
|
+
line=None,
|
|
363
|
+
message=f'{len(files)} files share escape reason: "{original_reason}"',
|
|
364
|
+
suggestion="Consider fixing the detection rule instead of adding escapes. "
|
|
365
|
+
f"Files: {', '.join(sorted(set(files))[:5])}"
|
|
366
|
+
+ (f" (+{len(files) - 5} more)" if len(files) > 5 else ""),
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
return violations
|
invar/core/rule_meta.py
CHANGED
|
@@ -106,6 +106,14 @@ RULE_META: dict[str, RuleMeta] = {
|
|
|
106
106
|
cannot_detect=("Runtime binding errors",),
|
|
107
107
|
hint="Lambda must accept ALL function parameters (include defaults like x=10)",
|
|
108
108
|
),
|
|
109
|
+
"postcondition_scope_error": RuleMeta(
|
|
110
|
+
name="postcondition_scope_error",
|
|
111
|
+
severity=Severity.ERROR,
|
|
112
|
+
category=RuleCategory.CONTRACTS,
|
|
113
|
+
detects="@post lambda references function parameters (not available in postcondition)",
|
|
114
|
+
cannot_detect=("Indirect parameter access via closures",),
|
|
115
|
+
hint="@post can only use 'result', not function parameters like x, y",
|
|
116
|
+
),
|
|
109
117
|
"must_use_ignored": RuleMeta(
|
|
110
118
|
name="must_use_ignored",
|
|
111
119
|
severity=Severity.WARNING,
|
invar/core/rules.py
CHANGED
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Callable
|
|
6
6
|
|
|
7
|
-
from deal import post
|
|
7
|
+
from deal import post
|
|
8
8
|
|
|
9
9
|
from invar.core.contracts import (
|
|
10
10
|
check_empty_contracts,
|
|
@@ -12,21 +12,16 @@ from invar.core.contracts import (
|
|
|
12
12
|
check_partial_contract,
|
|
13
13
|
check_redundant_type_contracts,
|
|
14
14
|
check_semantic_tautology,
|
|
15
|
-
check_skip_without_reason,
|
|
15
|
+
check_skip_without_reason,
|
|
16
16
|
)
|
|
17
17
|
from invar.core.entry_points import get_symbol_lines, has_allow_marker, is_entry_point
|
|
18
18
|
from invar.core.extraction import format_extraction_hint
|
|
19
19
|
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
20
20
|
from invar.core.must_use import check_must_use
|
|
21
|
+
from invar.core.postcondition_scope import check_postcondition_scope
|
|
21
22
|
from invar.core.purity import check_impure_calls, check_internal_imports
|
|
22
|
-
from invar.core.review_trigger import
|
|
23
|
-
|
|
24
|
-
check_review_suggested, # DX-31
|
|
25
|
-
)
|
|
26
|
-
from invar.core.shell_architecture import (
|
|
27
|
-
check_shell_pure_logic,
|
|
28
|
-
check_shell_too_complex,
|
|
29
|
-
)
|
|
23
|
+
from invar.core.review_trigger import check_contract_quality_ratio, check_review_suggested
|
|
24
|
+
from invar.core.shell_architecture import check_shell_pure_logic, check_shell_too_complex
|
|
30
25
|
from invar.core.suggestions import format_suggestion_for_violation
|
|
31
26
|
from invar.core.utils import get_excluded_rules
|
|
32
27
|
|
|
@@ -58,7 +53,6 @@ def _build_size_suggestion(base: str, extraction_hint: str, func_hint: str) -> s
|
|
|
58
53
|
return f"{base}{func_hint}" if func_hint else base
|
|
59
54
|
|
|
60
55
|
|
|
61
|
-
@pre(lambda file_info: isinstance(file_info, FileInfo))
|
|
62
56
|
@post(lambda result: isinstance(result, str))
|
|
63
57
|
def _get_func_hint(file_info: FileInfo) -> str:
|
|
64
58
|
"""Get top 5 largest functions as hint string."""
|
|
@@ -69,7 +63,7 @@ def _get_func_hint(file_info: FileInfo) -> str:
|
|
|
69
63
|
return f" Functions: {', '.join(f'{n}({sz}L)' for n, sz in funcs)}" if funcs else ""
|
|
70
64
|
|
|
71
65
|
|
|
72
|
-
@
|
|
66
|
+
@post(lambda result: all(v.rule in ("file_size", "file_size_warning") for v in result))
|
|
73
67
|
def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
74
68
|
"""
|
|
75
69
|
Check if file exceeds maximum line count or warning threshold.
|
|
@@ -110,7 +104,7 @@ def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
110
104
|
return violations
|
|
111
105
|
|
|
112
106
|
|
|
113
|
-
@
|
|
107
|
+
@post(lambda result: all(v.rule == "function_size" for v in result))
|
|
114
108
|
def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
115
109
|
"""
|
|
116
110
|
Check if any function exceeds maximum line count.
|
|
@@ -158,7 +152,7 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
|
|
|
158
152
|
return violations
|
|
159
153
|
|
|
160
154
|
|
|
161
|
-
@
|
|
155
|
+
@post(lambda result: all(v.rule == "forbidden_import" for v in result))
|
|
162
156
|
def check_forbidden_imports(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
163
157
|
"""
|
|
164
158
|
Check for forbidden imports in Core files.
|
|
@@ -202,7 +196,7 @@ def check_forbidden_imports(file_info: FileInfo, config: RuleConfig) -> list[Vio
|
|
|
202
196
|
return violations
|
|
203
197
|
|
|
204
198
|
|
|
205
|
-
@
|
|
199
|
+
@post(lambda result: all(v.rule == "missing_contract" for v in result))
|
|
206
200
|
def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
207
201
|
"""
|
|
208
202
|
Check that public Core functions have contracts.
|
|
@@ -223,9 +217,13 @@ def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
223
217
|
if not file_info.is_core or not config.require_contracts:
|
|
224
218
|
return violations
|
|
225
219
|
|
|
220
|
+
source = file_info.source or ""
|
|
226
221
|
for symbol in file_info.symbols:
|
|
227
222
|
# Check all functions and methods - agent needs contracts everywhere
|
|
228
223
|
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD) and not symbol.contracts:
|
|
224
|
+
# DX-22: Skip if @invar:allow marker present
|
|
225
|
+
if has_allow_marker(symbol, source, "missing_contract"):
|
|
226
|
+
continue
|
|
229
227
|
kind_name = "Method" if symbol.kind == SymbolKind.METHOD else "Function"
|
|
230
228
|
suggestion = format_suggestion_for_violation(symbol, "missing_contract")
|
|
231
229
|
violations.append(
|
|
@@ -242,7 +240,7 @@ def check_contracts(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
242
240
|
return violations
|
|
243
241
|
|
|
244
242
|
|
|
245
|
-
@
|
|
243
|
+
@post(lambda result: all(v.rule == "missing_doctest" for v in result))
|
|
246
244
|
def check_doctests(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
247
245
|
"""
|
|
248
246
|
Check that contracted functions have doctest examples.
|
|
@@ -292,7 +290,7 @@ def check_doctests(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
292
290
|
return violations
|
|
293
291
|
|
|
294
292
|
|
|
295
|
-
@
|
|
293
|
+
@post(lambda result: all(v.rule == "shell_result" for v in result))
|
|
296
294
|
def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
297
295
|
"""
|
|
298
296
|
Check that Shell functions with return values use Result[T, E].
|
|
@@ -344,7 +342,7 @@ def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violatio
|
|
|
344
342
|
return violations
|
|
345
343
|
|
|
346
344
|
|
|
347
|
-
@
|
|
345
|
+
@post(lambda result: all(v.rule == "entry_point_too_thick" for v in result))
|
|
348
346
|
def check_entry_point_thin(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
349
347
|
"""
|
|
350
348
|
Check that entry points are thin (DX-23).
|
|
@@ -415,6 +413,7 @@ def get_all_rules() -> list[RuleFunc]:
|
|
|
415
413
|
check_redundant_type_contracts,
|
|
416
414
|
check_param_mismatch,
|
|
417
415
|
check_partial_contract,
|
|
416
|
+
check_postcondition_scope,
|
|
418
417
|
check_must_use,
|
|
419
418
|
check_skip_without_reason, # DX-28
|
|
420
419
|
check_contract_quality_ratio, # DX-30
|
|
@@ -462,7 +461,7 @@ def _apply_severity_override(v: Violation, overrides: dict[str, str]) -> Violati
|
|
|
462
461
|
)
|
|
463
462
|
|
|
464
463
|
|
|
465
|
-
@
|
|
464
|
+
@post(lambda result: all(v.rule and v.file for v in result) if result else True)
|
|
466
465
|
def check_all_rules(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
467
466
|
"""
|
|
468
467
|
Run all rules against a file and collect violations.
|
invar/core/shell_analysis.py
CHANGED
|
@@ -93,8 +93,7 @@ COMPLEXITY_MARKER_PATTERN = re.compile(r"#\s*@shell_complexity\s*:")
|
|
|
93
93
|
ORCHESTRATION_MARKER_PATTERN = re.compile(r"#\s*@shell_orchestration\s*:")
|
|
94
94
|
|
|
95
95
|
|
|
96
|
-
@
|
|
97
|
-
@post(lambda result: isinstance(result, bool))
|
|
96
|
+
# @invar:allow missing_contract: Boolean predicate, empty string is valid input
|
|
98
97
|
def has_io_operations(source: str) -> bool:
|
|
99
98
|
"""
|
|
100
99
|
Check if source code contains I/O operations.
|
|
@@ -112,8 +111,7 @@ def has_io_operations(source: str) -> bool:
|
|
|
112
111
|
return any(indicator in source for indicator in IO_INDICATORS)
|
|
113
112
|
|
|
114
113
|
|
|
115
|
-
@pre(lambda symbol, source: symbol is not None
|
|
116
|
-
@post(lambda result: isinstance(result, bool))
|
|
114
|
+
@pre(lambda symbol, source: symbol is not None) # Symbol must exist
|
|
117
115
|
def has_orchestration_marker(symbol: Symbol, source: str) -> bool:
|
|
118
116
|
"""
|
|
119
117
|
Check if symbol has @shell_orchestration marker comment.
|
|
@@ -146,8 +144,7 @@ def has_orchestration_marker(symbol: Symbol, source: str) -> bool:
|
|
|
146
144
|
return bool(ORCHESTRATION_MARKER_PATTERN.search(context))
|
|
147
145
|
|
|
148
146
|
|
|
149
|
-
@pre(lambda symbol, source: symbol is not None
|
|
150
|
-
@post(lambda result: isinstance(result, bool))
|
|
147
|
+
@pre(lambda symbol, source: symbol is not None) # Symbol must exist
|
|
151
148
|
def has_complexity_marker(symbol: Symbol, source: str) -> bool:
|
|
152
149
|
"""
|
|
153
150
|
Check if symbol has @shell_complexity marker comment.
|
|
@@ -181,8 +178,7 @@ def has_complexity_marker(symbol: Symbol, source: str) -> bool:
|
|
|
181
178
|
return bool(COMPLEXITY_MARKER_PATTERN.search(context))
|
|
182
179
|
|
|
183
180
|
|
|
184
|
-
@
|
|
185
|
-
@post(lambda result: isinstance(result, int) and result >= 0)
|
|
181
|
+
@post(lambda result: result >= 0) # Branch count is non-negative
|
|
186
182
|
def count_branches(source: str) -> int:
|
|
187
183
|
"""
|
|
188
184
|
Count the number of branches in source code.
|
|
@@ -225,8 +221,7 @@ def count_branches(source: str) -> int:
|
|
|
225
221
|
return count
|
|
226
222
|
|
|
227
223
|
|
|
228
|
-
@pre(lambda symbol, file_source: symbol is not None
|
|
229
|
-
@post(lambda result: isinstance(result, str))
|
|
224
|
+
@pre(lambda symbol, file_source: symbol is not None) # Symbol must exist
|
|
230
225
|
def get_symbol_source(symbol: Symbol, file_source: str) -> str:
|
|
231
226
|
"""
|
|
232
227
|
Extract the source code for a specific symbol.
|
invar/core/shell_architecture.py
CHANGED
|
@@ -24,7 +24,7 @@ from invar.core.shell_analysis import (
|
|
|
24
24
|
)
|
|
25
25
|
|
|
26
26
|
|
|
27
|
-
@
|
|
27
|
+
@post(lambda result: all(v.rule == "shell_pure_logic" for v in result))
|
|
28
28
|
def check_shell_pure_logic(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
29
29
|
"""
|
|
30
30
|
Check that Shell functions contain I/O operations (DX-22).
|
|
@@ -79,7 +79,7 @@ def check_shell_pure_logic(file_info: FileInfo, config: RuleConfig) -> list[Viol
|
|
|
79
79
|
return violations
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
@
|
|
82
|
+
@post(lambda result: all(v.rule == "shell_too_complex" for v in result))
|
|
83
83
|
def check_shell_too_complex(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
84
84
|
"""
|
|
85
85
|
Check that Shell functions don't have excessive branching (DX-22).
|