invar-tools 1.0.0__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- invar/core/contracts.py +75 -5
- invar/core/entry_points.py +294 -0
- invar/core/format_specs.py +196 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +27 -4
- invar/core/hypothesis_strategies.py +47 -5
- invar/core/lambda_helpers.py +1 -0
- invar/core/models.py +23 -17
- invar/core/parser.py +6 -2
- invar/core/property_gen.py +81 -40
- invar/core/purity.py +10 -4
- invar/core/review_trigger.py +298 -0
- invar/core/rule_meta.py +61 -2
- invar/core/rules.py +83 -19
- invar/core/shell_analysis.py +252 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/suggestions.py +6 -0
- invar/core/tautology.py +1 -0
- invar/core/utils.py +51 -4
- invar/core/verification_routing.py +158 -0
- invar/invariant.py +1 -0
- invar/mcp/server.py +20 -3
- invar/shell/cli.py +59 -31
- invar/shell/config.py +259 -10
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +78 -3
- invar/shell/guard_output.py +100 -24
- invar/shell/init_cmd.py +27 -7
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutate_cmd.py +184 -0
- invar/shell/mutation.py +314 -0
- invar/shell/perception.py +2 -0
- invar/shell/property_tests.py +17 -2
- invar/shell/prove.py +35 -3
- invar/shell/prove_accept.py +113 -0
- invar/shell/prove_fallback.py +148 -46
- invar/shell/templates.py +34 -0
- invar/shell/test_cmd.py +3 -1
- invar/shell/testing.py +6 -17
- invar/shell/update_cmd.py +2 -0
- invar/templates/CLAUDE.md.template +65 -9
- invar/templates/INVAR.md +96 -23
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/review.md +200 -0
- invar/templates/cursorrules.template +22 -13
- invar/templates/examples/contracts.py +3 -1
- invar/templates/examples/core_shell.py +3 -1
- {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/METADATA +81 -15
- invar_tools-1.2.0.dist-info/RECORD +77 -0
- invar_tools-1.2.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.2.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.2.0.dist-info/licenses/NOTICE +63 -0
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.0.0.dist-info → invar_tools-1.2.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Review trigger detection for DX-30 Visible Workflow and DX-31 Independent Adversarial Reviewer.
|
|
3
|
+
|
|
4
|
+
DX-30: Contract quality ratio check (80% threshold)
|
|
5
|
+
DX-31: Independent review triggers (escape count, coverage, security)
|
|
6
|
+
|
|
7
|
+
Core module: pure logic, no I/O.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from deal import post, pre
|
|
15
|
+
|
|
16
|
+
from invar.core.entry_points import count_escape_hatches
|
|
17
|
+
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
18
|
+
|
|
19
|
+
# DX-31: Security-sensitive path patterns that trigger review suggestion
|
|
20
|
+
# Split into two groups to reduce false positives:
|
|
21
|
+
|
|
22
|
+
# Patterns safe to match as substrings (authentication, cryptography are valid matches)
|
|
23
|
+
SECURITY_SUBSTRING_PATTERNS: tuple[str, ...] = (
|
|
24
|
+
"auth", # authentication, authorize, authority
|
|
25
|
+
"crypt", # cryptography, encrypt, decrypt
|
|
26
|
+
"secret",
|
|
27
|
+
"password",
|
|
28
|
+
"credential",
|
|
29
|
+
"permission",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Patterns that must be exact word matches (to avoid keyboard, tokenizer, accessory)
|
|
33
|
+
SECURITY_WORD_PATTERNS: tuple[str, ...] = (
|
|
34
|
+
"token", # not tokenizer
|
|
35
|
+
"key", # not keyboard, monkey
|
|
36
|
+
"session", # not obsession
|
|
37
|
+
"access", # not accessory
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pre(lambda file_info: isinstance(file_info, FileInfo))
|
|
42
|
+
@post(lambda result: isinstance(result, tuple) and len(result) == 3)
|
|
43
|
+
def calculate_contract_ratio(file_info: FileInfo) -> tuple[float, int, int]:
|
|
44
|
+
"""
|
|
45
|
+
Calculate contract coverage ratio for a file (DX-31).
|
|
46
|
+
|
|
47
|
+
Returns (ratio, total_functions, with_contracts).
|
|
48
|
+
Only counts public functions (not starting with _).
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
>>> from invar.core.models import Contract, Symbol, SymbolKind
|
|
52
|
+
>>> # No functions
|
|
53
|
+
>>> empty = FileInfo(path="empty.py", lines=10)
|
|
54
|
+
>>> calculate_contract_ratio(empty)
|
|
55
|
+
(1.0, 0, 0)
|
|
56
|
+
>>> # 100% coverage
|
|
57
|
+
>>> c = Contract(kind="pre", expression="x > 0", line=1)
|
|
58
|
+
>>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
|
|
59
|
+
>>> full = FileInfo(path="full.py", lines=10, symbols=[sym])
|
|
60
|
+
>>> calculate_contract_ratio(full)
|
|
61
|
+
(1.0, 1, 1)
|
|
62
|
+
>>> # 0% coverage
|
|
63
|
+
>>> sym_no = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
64
|
+
>>> none = FileInfo(path="none.py", lines=10, symbols=[sym_no])
|
|
65
|
+
>>> calculate_contract_ratio(none)
|
|
66
|
+
(0.0, 1, 0)
|
|
67
|
+
>>> # Private functions ignored
|
|
68
|
+
>>> priv = Symbol(name="_helper", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
69
|
+
>>> private_only = FileInfo(path="priv.py", lines=10, symbols=[priv])
|
|
70
|
+
>>> calculate_contract_ratio(private_only)
|
|
71
|
+
(1.0, 0, 0)
|
|
72
|
+
"""
|
|
73
|
+
# Only check public functions (not starting with _)
|
|
74
|
+
# MINOR-9: This excludes dunder methods (__init__, __str__, etc.) which is intentional.
|
|
75
|
+
# Dunder methods are boilerplate; public API methods are the focus of contract coverage.
|
|
76
|
+
functions = [
|
|
77
|
+
s for s in file_info.symbols
|
|
78
|
+
if s.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD) and not s.name.startswith("_")
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
if not functions:
|
|
82
|
+
return (1.0, 0, 0)
|
|
83
|
+
|
|
84
|
+
total = len(functions)
|
|
85
|
+
with_contracts = sum(1 for f in functions if f.contracts)
|
|
86
|
+
ratio = with_contracts / total
|
|
87
|
+
|
|
88
|
+
return (ratio, total, with_contracts)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
92
|
+
def check_contract_quality_ratio(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
93
|
+
"""
|
|
94
|
+
Check contract coverage ratio in Core files (DX-30).
|
|
95
|
+
|
|
96
|
+
WARNING if less than 80% of public functions have @pre or @post.
|
|
97
|
+
This encourages "Contract before Implement" workflow.
|
|
98
|
+
|
|
99
|
+
Examples:
|
|
100
|
+
>>> # Shell file - no check
|
|
101
|
+
>>> shell_info = FileInfo(path="shell/cli.py", lines=50, is_shell=True)
|
|
102
|
+
>>> check_contract_quality_ratio(shell_info, RuleConfig())
|
|
103
|
+
[]
|
|
104
|
+
>>> # Core file with 100% coverage - pass
|
|
105
|
+
>>> from invar.core.models import Contract, Symbol
|
|
106
|
+
>>> c = Contract(kind="pre", expression="x > 0", line=1)
|
|
107
|
+
>>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
|
|
108
|
+
>>> core_ok = FileInfo(path="core/calc.py", lines=50, symbols=[sym], is_core=True)
|
|
109
|
+
>>> check_contract_quality_ratio(core_ok, RuleConfig())
|
|
110
|
+
[]
|
|
111
|
+
>>> # Core file with 0% coverage - warning
|
|
112
|
+
>>> sym_no = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
113
|
+
>>> core_bad = FileInfo(path="core/calc.py", lines=50, symbols=[sym_no], is_core=True)
|
|
114
|
+
>>> vs = check_contract_quality_ratio(core_bad, RuleConfig())
|
|
115
|
+
>>> len(vs) == 1 and vs[0].rule == "contract_quality_ratio"
|
|
116
|
+
True
|
|
117
|
+
"""
|
|
118
|
+
violations: list[Violation] = []
|
|
119
|
+
|
|
120
|
+
if not file_info.is_core:
|
|
121
|
+
return violations
|
|
122
|
+
|
|
123
|
+
ratio, total, with_contracts = calculate_contract_ratio(file_info)
|
|
124
|
+
|
|
125
|
+
if total == 0:
|
|
126
|
+
return violations
|
|
127
|
+
|
|
128
|
+
if ratio < 0.8:
|
|
129
|
+
pct = int(ratio * 100)
|
|
130
|
+
violations.append(
|
|
131
|
+
Violation(
|
|
132
|
+
rule="contract_quality_ratio",
|
|
133
|
+
severity=Severity.WARNING,
|
|
134
|
+
file=file_info.path,
|
|
135
|
+
line=None,
|
|
136
|
+
message=f"Contract coverage: {pct}% ({with_contracts}/{total}). Target: 80%+",
|
|
137
|
+
suggestion="Add @pre/@post to public functions. See INVAR.md 'Visible Workflow'",
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return violations
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pre(lambda path: isinstance(path, str))
|
|
145
|
+
@post(lambda result: isinstance(result, bool))
|
|
146
|
+
def is_security_sensitive(path: str) -> bool:
|
|
147
|
+
"""
|
|
148
|
+
Check if path indicates security-sensitive code (DX-31).
|
|
149
|
+
|
|
150
|
+
Uses two-tier matching to reduce false positives:
|
|
151
|
+
- Substring matching for unambiguous patterns (auth, crypt, secret, etc.)
|
|
152
|
+
- Word-exact matching for ambiguous patterns (key, token, access, session)
|
|
153
|
+
|
|
154
|
+
Examples:
|
|
155
|
+
>>> # Substring patterns (auth, crypt, secret, password, credential, permission)
|
|
156
|
+
>>> is_security_sensitive("src/auth/login.py")
|
|
157
|
+
True
|
|
158
|
+
>>> is_security_sensitive("src/authentication.py")
|
|
159
|
+
True
|
|
160
|
+
>>> is_security_sensitive("src/core/crypto.py")
|
|
161
|
+
True
|
|
162
|
+
>>> is_security_sensitive("src/utils/helpers.py")
|
|
163
|
+
False
|
|
164
|
+
|
|
165
|
+
>>> # Word-exact patterns (token, key, session, access)
|
|
166
|
+
>>> is_security_sensitive("src/token_handler.py")
|
|
167
|
+
True
|
|
168
|
+
>>> is_security_sensitive("src/api_key.py")
|
|
169
|
+
True
|
|
170
|
+
>>> is_security_sensitive("src/access_control.py")
|
|
171
|
+
True
|
|
172
|
+
|
|
173
|
+
>>> # False positive prevention
|
|
174
|
+
>>> is_security_sensitive("src/tokenizer.py")
|
|
175
|
+
False
|
|
176
|
+
>>> is_security_sensitive("src/keyboard.py")
|
|
177
|
+
False
|
|
178
|
+
>>> is_security_sensitive("src/monkey.py")
|
|
179
|
+
False
|
|
180
|
+
>>> is_security_sensitive("src/accessory.py")
|
|
181
|
+
False
|
|
182
|
+
>>> is_security_sensitive("src/hockey.py")
|
|
183
|
+
False
|
|
184
|
+
|
|
185
|
+
>>> # Edge cases
|
|
186
|
+
>>> is_security_sensitive("")
|
|
187
|
+
False
|
|
188
|
+
"""
|
|
189
|
+
if not path:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
path_lower = path.lower()
|
|
193
|
+
|
|
194
|
+
# Check substring patterns (safe, low false positive rate)
|
|
195
|
+
if any(pattern in path_lower for pattern in SECURITY_SUBSTRING_PATTERNS):
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
# Check word-exact patterns (split path into words first)
|
|
199
|
+
words = re.split(r"[/_.\-\\]", path_lower)
|
|
200
|
+
return any(word in SECURITY_WORD_PATTERNS for word in words)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
204
|
+
def check_review_suggested(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
205
|
+
"""
|
|
206
|
+
Suggest independent review when conditions warrant (DX-31).
|
|
207
|
+
|
|
208
|
+
Triggers review suggestion for Core files when:
|
|
209
|
+
- escape_count >= 3: Multiple escape hatches indicate complexity
|
|
210
|
+
- contract_ratio < 50%: Low contract coverage needs review
|
|
211
|
+
- security-sensitive path: Security code needs extra scrutiny
|
|
212
|
+
|
|
213
|
+
Examples:
|
|
214
|
+
>>> from invar.core.models import Contract, Symbol, SymbolKind
|
|
215
|
+
>>> # Shell file - no check
|
|
216
|
+
>>> shell = FileInfo(path="shell/cli.py", lines=50, is_shell=True)
|
|
217
|
+
>>> check_review_suggested(shell, RuleConfig())
|
|
218
|
+
[]
|
|
219
|
+
>>> # Core file with no triggers - pass
|
|
220
|
+
>>> c = Contract(kind="pre", expression="x > 0", line=1)
|
|
221
|
+
>>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
|
|
222
|
+
>>> core_ok = FileInfo(path="core/calc.py", lines=50, symbols=[sym], is_core=True)
|
|
223
|
+
>>> check_review_suggested(core_ok, RuleConfig())
|
|
224
|
+
[]
|
|
225
|
+
>>> # Security-sensitive path - warning
|
|
226
|
+
>>> auth = FileInfo(path="core/auth/login.py", lines=50, is_core=True, source="")
|
|
227
|
+
>>> vs = check_review_suggested(auth, RuleConfig())
|
|
228
|
+
>>> len(vs) == 1 and vs[0].rule == "review_suggested"
|
|
229
|
+
True
|
|
230
|
+
>>> # Low contract ratio - warning
|
|
231
|
+
>>> sym_no = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
232
|
+
>>> low = FileInfo(path="core/calc.py", lines=50, symbols=[sym_no], is_core=True)
|
|
233
|
+
>>> vs = check_review_suggested(low, RuleConfig())
|
|
234
|
+
>>> len(vs) == 1 and "contract" in vs[0].message.lower()
|
|
235
|
+
True
|
|
236
|
+
>>> # Multiple escape hatches - warning
|
|
237
|
+
>>> source = '''
|
|
238
|
+
... # @invar:allow rule1: reason1
|
|
239
|
+
... # @invar:allow rule2: reason2
|
|
240
|
+
... # @invar:allow rule3: reason3
|
|
241
|
+
... '''
|
|
242
|
+
>>> escapes = FileInfo(path="core/complex.py", lines=50, is_core=True, source=source, symbols=[sym])
|
|
243
|
+
>>> vs = check_review_suggested(escapes, RuleConfig())
|
|
244
|
+
>>> len(vs) == 1 and "escape" in vs[0].message.lower()
|
|
245
|
+
True
|
|
246
|
+
"""
|
|
247
|
+
violations: list[Violation] = []
|
|
248
|
+
|
|
249
|
+
# Only check Core files
|
|
250
|
+
if not file_info.is_core:
|
|
251
|
+
return violations
|
|
252
|
+
|
|
253
|
+
# Trigger 1: Security-sensitive path
|
|
254
|
+
if is_security_sensitive(file_info.path):
|
|
255
|
+
violations.append(
|
|
256
|
+
Violation(
|
|
257
|
+
rule="review_suggested",
|
|
258
|
+
severity=Severity.WARNING,
|
|
259
|
+
file=file_info.path,
|
|
260
|
+
line=None,
|
|
261
|
+
message=f"Security-sensitive file: {file_info.path}",
|
|
262
|
+
suggestion="Consider independent /review before task completion",
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
return violations # Only one suggestion per file
|
|
266
|
+
|
|
267
|
+
# Trigger 2: Multiple escape hatches (>= 3)
|
|
268
|
+
source = file_info.source or ""
|
|
269
|
+
escape_count = count_escape_hatches(source)
|
|
270
|
+
if escape_count >= 3:
|
|
271
|
+
violations.append(
|
|
272
|
+
Violation(
|
|
273
|
+
rule="review_suggested",
|
|
274
|
+
severity=Severity.WARNING,
|
|
275
|
+
file=file_info.path,
|
|
276
|
+
line=None,
|
|
277
|
+
message=f"High escape hatch count: {escape_count} @invar:allow markers",
|
|
278
|
+
suggestion="Consider independent /review to validate escape justifications",
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
return violations # Only one suggestion per file
|
|
282
|
+
|
|
283
|
+
# Trigger 3: Low contract ratio (< 50%)
|
|
284
|
+
ratio, total, _ = calculate_contract_ratio(file_info)
|
|
285
|
+
if total > 0 and ratio < 0.5:
|
|
286
|
+
pct = int(ratio * 100)
|
|
287
|
+
violations.append(
|
|
288
|
+
Violation(
|
|
289
|
+
rule="review_suggested",
|
|
290
|
+
severity=Severity.WARNING,
|
|
291
|
+
file=file_info.path,
|
|
292
|
+
line=None,
|
|
293
|
+
message=f"Low contract coverage: {pct}% (threshold: 50%)",
|
|
294
|
+
suggestion="Add contracts, or request independent /review to assess quality",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return violations
|
invar/core/rule_meta.py
CHANGED
|
@@ -142,11 +142,43 @@ RULE_META: dict[str, RuleMeta] = {
|
|
|
142
142
|
# Shell rules
|
|
143
143
|
"shell_result": RuleMeta(
|
|
144
144
|
name="shell_result",
|
|
145
|
-
severity=Severity.
|
|
145
|
+
severity=Severity.ERROR, # DX-22: Architecture rule, must fix or explain
|
|
146
146
|
category=RuleCategory.SHELL,
|
|
147
147
|
detects="Shell function not returning Result[T, E]",
|
|
148
148
|
cannot_detect=("Result usage correctness", "Error handling quality"),
|
|
149
|
-
hint="Wrap
|
|
149
|
+
hint="Wrap with Success()/Failure(), or add: # @invar:allow shell_result: <reason>",
|
|
150
|
+
),
|
|
151
|
+
"entry_point_too_thick": RuleMeta(
|
|
152
|
+
name="entry_point_too_thick",
|
|
153
|
+
severity=Severity.ERROR, # DX-22: Architecture rule, must fix or explain
|
|
154
|
+
category=RuleCategory.SHELL,
|
|
155
|
+
detects="Entry point (Flask route, Typer command, etc.) exceeds max lines",
|
|
156
|
+
cannot_detect=("Whether complexity is unavoidable", "Framework constraints"),
|
|
157
|
+
hint="Move logic to Shell function, or add: # @invar:allow entry_point_too_thick: <reason>",
|
|
158
|
+
),
|
|
159
|
+
"shell_pure_logic": RuleMeta(
|
|
160
|
+
name="shell_pure_logic",
|
|
161
|
+
severity=Severity.WARNING,
|
|
162
|
+
category=RuleCategory.SHELL,
|
|
163
|
+
detects="Shell function with no I/O operations (pure logic belongs in Core)",
|
|
164
|
+
cannot_detect=("Indirect I/O via method calls", "Framework-specific patterns"),
|
|
165
|
+
hint="Move to Core layer and add @pre/@post contracts, or add: # @shell_orchestration: <reason>",
|
|
166
|
+
),
|
|
167
|
+
"shell_too_complex": RuleMeta(
|
|
168
|
+
name="shell_too_complex",
|
|
169
|
+
severity=Severity.INFO,
|
|
170
|
+
category=RuleCategory.SHELL,
|
|
171
|
+
detects="Shell function with excessive branching complexity",
|
|
172
|
+
cannot_detect=("Whether complexity is justified", "Domain-specific patterns"),
|
|
173
|
+
hint="Extract logic to Core, or add: # @shell_complexity: <reason>",
|
|
174
|
+
),
|
|
175
|
+
"shell_complexity_debt": RuleMeta(
|
|
176
|
+
name="shell_complexity_debt",
|
|
177
|
+
severity=Severity.ERROR,
|
|
178
|
+
category=RuleCategory.SHELL,
|
|
179
|
+
detects="Project accumulated too many unaddressed complexity warnings (DX-22 Fix-or-Explain)",
|
|
180
|
+
cannot_detect=("Individual function justifications",),
|
|
181
|
+
hint="Address shell_too_complex warnings: refactor OR add @shell_complexity: markers",
|
|
150
182
|
),
|
|
151
183
|
# Documentation rules
|
|
152
184
|
"missing_doctest": RuleMeta(
|
|
@@ -157,6 +189,33 @@ RULE_META: dict[str, RuleMeta] = {
|
|
|
157
189
|
cannot_detect=("Doctest quality", "Edge case coverage"),
|
|
158
190
|
hint="Add >>> examples showing typical usage and edge cases",
|
|
159
191
|
),
|
|
192
|
+
# DX-28: Skip abuse prevention
|
|
193
|
+
"skip_without_reason": RuleMeta(
|
|
194
|
+
name="skip_without_reason",
|
|
195
|
+
severity=Severity.WARNING,
|
|
196
|
+
category=RuleCategory.CONTRACTS,
|
|
197
|
+
detects="@skip_property_test used without justification reason",
|
|
198
|
+
cannot_detect=("Whether the reason is valid", "Skip abuse patterns"),
|
|
199
|
+
hint='Add reason: @skip_property_test("category: explanation")',
|
|
200
|
+
),
|
|
201
|
+
# DX-30: Contract coverage ratio
|
|
202
|
+
"contract_quality_ratio": RuleMeta(
|
|
203
|
+
name="contract_quality_ratio",
|
|
204
|
+
severity=Severity.WARNING,
|
|
205
|
+
category=RuleCategory.CONTRACTS,
|
|
206
|
+
detects="Core file with less than 80% contract coverage on public functions",
|
|
207
|
+
cannot_detect=("Contract quality", "Whether contracts are meaningful"),
|
|
208
|
+
hint="Add @pre/@post to public functions. Use Phase TodoList for complex tasks",
|
|
209
|
+
),
|
|
210
|
+
# DX-31: Review suggestion trigger
|
|
211
|
+
"review_suggested": RuleMeta(
|
|
212
|
+
name="review_suggested",
|
|
213
|
+
severity=Severity.WARNING,
|
|
214
|
+
category=RuleCategory.DOCS,
|
|
215
|
+
detects="Conditions warranting independent code review (escape count, coverage, security)",
|
|
216
|
+
cannot_detect=("Whether review was performed", "Review quality"),
|
|
217
|
+
hint="Consider independent /review sub-agent before task completion",
|
|
218
|
+
),
|
|
160
219
|
}
|
|
161
220
|
|
|
162
221
|
|
invar/core/rules.py
CHANGED
|
@@ -12,11 +12,21 @@ 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, # DX-28
|
|
15
16
|
)
|
|
17
|
+
from invar.core.entry_points import get_symbol_lines, has_allow_marker, is_entry_point
|
|
16
18
|
from invar.core.extraction import format_extraction_hint
|
|
17
19
|
from invar.core.models import FileInfo, RuleConfig, Severity, SymbolKind, Violation
|
|
18
20
|
from invar.core.must_use import check_must_use
|
|
19
21
|
from invar.core.purity import check_impure_calls, check_internal_imports
|
|
22
|
+
from invar.core.review_trigger import (
|
|
23
|
+
check_contract_quality_ratio, # DX-30
|
|
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
|
+
)
|
|
20
30
|
from invar.core.suggestions import format_suggestion_for_violation
|
|
21
31
|
from invar.core.utils import get_excluded_rules
|
|
22
32
|
|
|
@@ -105,8 +115,8 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
|
|
|
105
115
|
"""
|
|
106
116
|
Check if any function exceeds maximum line count.
|
|
107
117
|
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
DX-22: Always uses code_lines (excluding docstring) and excludes doctest lines.
|
|
119
|
+
These behaviors were previously optional but are now the default.
|
|
110
120
|
|
|
111
121
|
Examples:
|
|
112
122
|
>>> from invar.core.models import FileInfo, Symbol, SymbolKind
|
|
@@ -121,26 +131,19 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
|
|
|
121
131
|
for symbol in file_info.symbols:
|
|
122
132
|
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
123
133
|
total_lines = symbol.end_line - symbol.line + 1
|
|
124
|
-
#
|
|
125
|
-
if
|
|
134
|
+
# DX-22: Always use code_lines when available (excluding docstring)
|
|
135
|
+
if symbol.code_lines is not None:
|
|
126
136
|
func_lines = symbol.code_lines
|
|
127
137
|
line_type = "code lines"
|
|
128
138
|
else:
|
|
129
139
|
func_lines = total_lines
|
|
130
140
|
line_type = "lines"
|
|
131
|
-
#
|
|
132
|
-
if
|
|
141
|
+
# DX-22: Always exclude doctest lines from size calculation
|
|
142
|
+
if symbol.doctest_lines > 0:
|
|
133
143
|
func_lines -= symbol.doctest_lines
|
|
134
144
|
line_type = f"{line_type} (excl. doctest)"
|
|
135
145
|
|
|
136
146
|
if func_lines > config.max_function_lines:
|
|
137
|
-
# P19: Show breakdown if doctest lines exist
|
|
138
|
-
if symbol.doctest_lines > 0 and not config.exclude_doctest_lines:
|
|
139
|
-
code_only = total_lines - symbol.doctest_lines
|
|
140
|
-
breakdown = f" ({code_only} code + {symbol.doctest_lines} doctest)"
|
|
141
|
-
suggestion = f"Extract helper or set exclude_doctest_lines=true{breakdown}"
|
|
142
|
-
else:
|
|
143
|
-
suggestion = "Extract helper functions"
|
|
144
147
|
violations.append(
|
|
145
148
|
Violation(
|
|
146
149
|
rule="function_size",
|
|
@@ -148,7 +151,7 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
|
|
|
148
151
|
file=file_info.path,
|
|
149
152
|
line=symbol.line,
|
|
150
153
|
message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {config.max_function_lines})",
|
|
151
|
-
suggestion=
|
|
154
|
+
suggestion="Extract helper functions",
|
|
152
155
|
)
|
|
153
156
|
)
|
|
154
157
|
|
|
@@ -294,7 +297,10 @@ def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violatio
|
|
|
294
297
|
"""
|
|
295
298
|
Check that Shell functions with return values use Result[T, E].
|
|
296
299
|
|
|
297
|
-
Skips:
|
|
300
|
+
Skips:
|
|
301
|
+
- Functions returning None (CLI entry points)
|
|
302
|
+
- Generators (Iterator/Generator/AsyncIterator/AsyncGenerator)
|
|
303
|
+
- Entry points (DX-23: framework callbacks like Flask routes, Typer commands)
|
|
298
304
|
|
|
299
305
|
Examples:
|
|
300
306
|
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
|
|
@@ -314,23 +320,75 @@ def check_shell_result(file_info: FileInfo, config: RuleConfig) -> list[Violatio
|
|
|
314
320
|
# Skip functions with no return type or returning None
|
|
315
321
|
if "-> None" in symbol.signature or "->" not in symbol.signature:
|
|
316
322
|
continue
|
|
317
|
-
# Skip generators (Iterator/Generator) - acceptable
|
|
318
|
-
|
|
323
|
+
# Skip generators (Iterator/Generator/AsyncIterator/AsyncGenerator) - acceptable per protocol
|
|
324
|
+
# MINOR-11: Added async variants
|
|
325
|
+
if any(
|
|
326
|
+
pattern in symbol.signature
|
|
327
|
+
for pattern in ("Iterator[", "Generator[", "AsyncIterator[", "AsyncGenerator[")
|
|
328
|
+
):
|
|
329
|
+
continue
|
|
330
|
+
# DX-23: Skip entry points; DX-22: Skip if @invar:allow marker
|
|
331
|
+
if is_entry_point(symbol, file_info.source) or has_allow_marker(symbol, file_info.source, "shell_result"):
|
|
319
332
|
continue
|
|
320
333
|
if "Result[" not in symbol.signature:
|
|
321
334
|
violations.append(
|
|
322
335
|
Violation(
|
|
323
336
|
rule="shell_result",
|
|
324
|
-
severity=Severity.
|
|
337
|
+
severity=Severity.ERROR, # DX-22: Architecture rule
|
|
325
338
|
file=file_info.path,
|
|
326
339
|
line=symbol.line,
|
|
327
340
|
message=f"Shell function '{symbol.name}' should return Result[T, E]",
|
|
328
|
-
suggestion="Use Result[T, E]
|
|
341
|
+
suggestion="Use Result[T, E], or add: # @invar:allow shell_result: <reason>",
|
|
329
342
|
)
|
|
330
343
|
)
|
|
331
344
|
return violations
|
|
332
345
|
|
|
333
346
|
|
|
347
|
+
@pre(lambda file_info, config: isinstance(file_info, FileInfo))
|
|
348
|
+
def check_entry_point_thin(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
349
|
+
"""
|
|
350
|
+
Check that entry points are thin (DX-23).
|
|
351
|
+
|
|
352
|
+
Entry points should delegate to Shell functions and not contain
|
|
353
|
+
business logic. They serve as "monad runners" at framework boundaries.
|
|
354
|
+
|
|
355
|
+
Examples:
|
|
356
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind, RuleConfig
|
|
357
|
+
>>> sym = Symbol(name="index", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
358
|
+
>>> source = '@app.route("/")\\ndef index(): pass'
|
|
359
|
+
>>> info = FileInfo(path="shell/web.py", lines=10, symbols=[sym], is_shell=True, source=source)
|
|
360
|
+
>>> check_entry_point_thin(info, RuleConfig())
|
|
361
|
+
[]
|
|
362
|
+
"""
|
|
363
|
+
violations: list[Violation] = []
|
|
364
|
+
if not file_info.is_shell:
|
|
365
|
+
return violations
|
|
366
|
+
|
|
367
|
+
max_lines = config.entry_max_lines
|
|
368
|
+
|
|
369
|
+
for symbol in file_info.symbols:
|
|
370
|
+
if symbol.kind != SymbolKind.FUNCTION:
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
# Only check entry points; DX-22: Skip if @invar:allow marker
|
|
374
|
+
if not is_entry_point(symbol, file_info.source) or has_allow_marker(symbol, file_info.source, "entry_point_too_thick"):
|
|
375
|
+
continue
|
|
376
|
+
lines = get_symbol_lines(symbol)
|
|
377
|
+
if lines > max_lines:
|
|
378
|
+
violations.append(
|
|
379
|
+
Violation(
|
|
380
|
+
rule="entry_point_too_thick",
|
|
381
|
+
severity=Severity.ERROR, # DX-22: Architecture rule
|
|
382
|
+
file=file_info.path,
|
|
383
|
+
line=symbol.line,
|
|
384
|
+
message=f"Entry point '{symbol.name}' has {lines} lines (max: {max_lines})",
|
|
385
|
+
suggestion="Move logic to Shell function, or add: # @invar:allow entry_point_too_thick: <reason>",
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
return violations
|
|
390
|
+
|
|
391
|
+
|
|
334
392
|
@post(lambda result: len(result) > 0)
|
|
335
393
|
def get_all_rules() -> list[RuleFunc]:
|
|
336
394
|
"""
|
|
@@ -347,6 +405,9 @@ def get_all_rules() -> list[RuleFunc]:
|
|
|
347
405
|
check_contracts,
|
|
348
406
|
check_doctests,
|
|
349
407
|
check_shell_result,
|
|
408
|
+
check_entry_point_thin, # DX-23
|
|
409
|
+
check_shell_pure_logic, # DX-22
|
|
410
|
+
check_shell_too_complex, # DX-22
|
|
350
411
|
check_internal_imports,
|
|
351
412
|
check_impure_calls,
|
|
352
413
|
check_empty_contracts,
|
|
@@ -355,6 +416,9 @@ def get_all_rules() -> list[RuleFunc]:
|
|
|
355
416
|
check_param_mismatch,
|
|
356
417
|
check_partial_contract,
|
|
357
418
|
check_must_use,
|
|
419
|
+
check_skip_without_reason, # DX-28
|
|
420
|
+
check_contract_quality_ratio, # DX-30
|
|
421
|
+
check_review_suggested, # DX-31
|
|
358
422
|
]
|
|
359
423
|
|
|
360
424
|
|