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
|
@@ -0,0 +1,370 @@
|
|
|
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
|
+
@post(lambda result: len(result) == 3 and 0.0 <= result[0] <= 1.0) # Ratio in [0, 1]
|
|
42
|
+
def calculate_contract_ratio(file_info: FileInfo) -> tuple[float, int, int]:
|
|
43
|
+
"""
|
|
44
|
+
Calculate contract coverage ratio for a file (DX-31).
|
|
45
|
+
|
|
46
|
+
Returns (ratio, total_functions, with_contracts).
|
|
47
|
+
Only counts public functions (not starting with _).
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
>>> from invar.core.models import Contract, Symbol, SymbolKind
|
|
51
|
+
>>> # No functions
|
|
52
|
+
>>> empty = FileInfo(path="empty.py", lines=10)
|
|
53
|
+
>>> calculate_contract_ratio(empty)
|
|
54
|
+
(1.0, 0, 0)
|
|
55
|
+
>>> # 100% coverage
|
|
56
|
+
>>> c = Contract(kind="pre", expression="x > 0", line=1)
|
|
57
|
+
>>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
|
|
58
|
+
>>> full = FileInfo(path="full.py", lines=10, symbols=[sym])
|
|
59
|
+
>>> calculate_contract_ratio(full)
|
|
60
|
+
(1.0, 1, 1)
|
|
61
|
+
>>> # 0% coverage
|
|
62
|
+
>>> sym_no = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
63
|
+
>>> none = FileInfo(path="none.py", lines=10, symbols=[sym_no])
|
|
64
|
+
>>> calculate_contract_ratio(none)
|
|
65
|
+
(0.0, 1, 0)
|
|
66
|
+
>>> # Private functions ignored
|
|
67
|
+
>>> priv = Symbol(name="_helper", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
68
|
+
>>> private_only = FileInfo(path="priv.py", lines=10, symbols=[priv])
|
|
69
|
+
>>> calculate_contract_ratio(private_only)
|
|
70
|
+
(1.0, 0, 0)
|
|
71
|
+
"""
|
|
72
|
+
# Only check public functions (not starting with _)
|
|
73
|
+
# MINOR-9: This excludes dunder methods (__init__, __str__, etc.) which is intentional.
|
|
74
|
+
# Dunder methods are boilerplate; public API methods are the focus of contract coverage.
|
|
75
|
+
functions = [
|
|
76
|
+
s for s in file_info.symbols
|
|
77
|
+
if s.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD) and not s.name.startswith("_")
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
if not functions:
|
|
81
|
+
return (1.0, 0, 0)
|
|
82
|
+
|
|
83
|
+
total = len(functions)
|
|
84
|
+
with_contracts = sum(1 for f in functions if f.contracts)
|
|
85
|
+
ratio = with_contracts / total
|
|
86
|
+
|
|
87
|
+
return (ratio, total, with_contracts)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@post(lambda result: all(v.rule == "contract_quality_ratio" for v in result))
|
|
91
|
+
def check_contract_quality_ratio(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
92
|
+
"""
|
|
93
|
+
Check contract coverage ratio in Core files (DX-30).
|
|
94
|
+
|
|
95
|
+
WARNING if less than 80% of public functions have @pre or @post.
|
|
96
|
+
This encourages "Contract before Implement" workflow.
|
|
97
|
+
|
|
98
|
+
Examples:
|
|
99
|
+
>>> # Shell file - no check
|
|
100
|
+
>>> shell_info = FileInfo(path="shell/cli.py", lines=50, is_shell=True)
|
|
101
|
+
>>> check_contract_quality_ratio(shell_info, RuleConfig())
|
|
102
|
+
[]
|
|
103
|
+
>>> # Core file with 100% coverage - pass
|
|
104
|
+
>>> from invar.core.models import Contract, Symbol
|
|
105
|
+
>>> c = Contract(kind="pre", expression="x > 0", line=1)
|
|
106
|
+
>>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
|
|
107
|
+
>>> core_ok = FileInfo(path="core/calc.py", lines=50, symbols=[sym], is_core=True)
|
|
108
|
+
>>> check_contract_quality_ratio(core_ok, RuleConfig())
|
|
109
|
+
[]
|
|
110
|
+
>>> # Core file with 0% coverage - warning
|
|
111
|
+
>>> sym_no = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
112
|
+
>>> core_bad = FileInfo(path="core/calc.py", lines=50, symbols=[sym_no], is_core=True)
|
|
113
|
+
>>> vs = check_contract_quality_ratio(core_bad, RuleConfig())
|
|
114
|
+
>>> len(vs) == 1 and vs[0].rule == "contract_quality_ratio"
|
|
115
|
+
True
|
|
116
|
+
"""
|
|
117
|
+
violations: list[Violation] = []
|
|
118
|
+
|
|
119
|
+
if not file_info.is_core:
|
|
120
|
+
return violations
|
|
121
|
+
|
|
122
|
+
ratio, total, with_contracts = calculate_contract_ratio(file_info)
|
|
123
|
+
|
|
124
|
+
if total == 0:
|
|
125
|
+
return violations
|
|
126
|
+
|
|
127
|
+
if ratio < 0.8:
|
|
128
|
+
pct = int(ratio * 100)
|
|
129
|
+
violations.append(
|
|
130
|
+
Violation(
|
|
131
|
+
rule="contract_quality_ratio",
|
|
132
|
+
severity=Severity.WARNING,
|
|
133
|
+
file=file_info.path,
|
|
134
|
+
line=None,
|
|
135
|
+
message=f"Contract coverage: {pct}% ({with_contracts}/{total}). Target: 80%+",
|
|
136
|
+
suggestion="Add @pre/@post to public functions. See INVAR.md 'Visible Workflow'",
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return violations
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# @invar:allow missing_contract: Boolean predicate, accepts empty string (doctest shows)
|
|
144
|
+
def is_security_sensitive(path: str) -> bool:
|
|
145
|
+
"""
|
|
146
|
+
Check if path indicates security-sensitive code (DX-31).
|
|
147
|
+
|
|
148
|
+
Uses two-tier matching to reduce false positives:
|
|
149
|
+
- Substring matching for unambiguous patterns (auth, crypt, secret, etc.)
|
|
150
|
+
- Word-exact matching for ambiguous patterns (key, token, access, session)
|
|
151
|
+
|
|
152
|
+
Examples:
|
|
153
|
+
>>> # Substring patterns (auth, crypt, secret, password, credential, permission)
|
|
154
|
+
>>> is_security_sensitive("src/auth/login.py")
|
|
155
|
+
True
|
|
156
|
+
>>> is_security_sensitive("src/authentication.py")
|
|
157
|
+
True
|
|
158
|
+
>>> is_security_sensitive("src/core/crypto.py")
|
|
159
|
+
True
|
|
160
|
+
>>> is_security_sensitive("src/utils/helpers.py")
|
|
161
|
+
False
|
|
162
|
+
|
|
163
|
+
>>> # Word-exact patterns (token, key, session, access)
|
|
164
|
+
>>> is_security_sensitive("src/token_handler.py")
|
|
165
|
+
True
|
|
166
|
+
>>> is_security_sensitive("src/api_key.py")
|
|
167
|
+
True
|
|
168
|
+
>>> is_security_sensitive("src/access_control.py")
|
|
169
|
+
True
|
|
170
|
+
|
|
171
|
+
>>> # False positive prevention
|
|
172
|
+
>>> is_security_sensitive("src/tokenizer.py")
|
|
173
|
+
False
|
|
174
|
+
>>> is_security_sensitive("src/keyboard.py")
|
|
175
|
+
False
|
|
176
|
+
>>> is_security_sensitive("src/monkey.py")
|
|
177
|
+
False
|
|
178
|
+
>>> is_security_sensitive("src/accessory.py")
|
|
179
|
+
False
|
|
180
|
+
>>> is_security_sensitive("src/hockey.py")
|
|
181
|
+
False
|
|
182
|
+
|
|
183
|
+
>>> # Edge cases
|
|
184
|
+
>>> is_security_sensitive("")
|
|
185
|
+
False
|
|
186
|
+
"""
|
|
187
|
+
if not path:
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
path_lower = path.lower()
|
|
191
|
+
|
|
192
|
+
# Check substring patterns (safe, low false positive rate)
|
|
193
|
+
if any(pattern in path_lower for pattern in SECURITY_SUBSTRING_PATTERNS):
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
# Check word-exact patterns (split path into words first)
|
|
197
|
+
words = re.split(r"[/_.\-\\]", path_lower)
|
|
198
|
+
return any(word in SECURITY_WORD_PATTERNS for word in words)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@post(lambda result: all(v.rule == "review_suggested" for v in result))
|
|
202
|
+
def check_review_suggested(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
203
|
+
"""
|
|
204
|
+
Suggest independent review when conditions warrant (DX-31).
|
|
205
|
+
|
|
206
|
+
Triggers review suggestion for Core files when:
|
|
207
|
+
- escape_count >= 3: Multiple escape hatches indicate complexity
|
|
208
|
+
- contract_ratio < 50%: Low contract coverage needs review
|
|
209
|
+
- security-sensitive path: Security code needs extra scrutiny
|
|
210
|
+
|
|
211
|
+
Examples:
|
|
212
|
+
>>> from invar.core.models import Contract, Symbol, SymbolKind
|
|
213
|
+
>>> # Shell file - no check
|
|
214
|
+
>>> shell = FileInfo(path="shell/cli.py", lines=50, is_shell=True)
|
|
215
|
+
>>> check_review_suggested(shell, RuleConfig())
|
|
216
|
+
[]
|
|
217
|
+
>>> # Core file with no triggers - pass
|
|
218
|
+
>>> c = Contract(kind="pre", expression="x > 0", line=1)
|
|
219
|
+
>>> sym = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5, contracts=[c])
|
|
220
|
+
>>> core_ok = FileInfo(path="core/calc.py", lines=50, symbols=[sym], is_core=True)
|
|
221
|
+
>>> check_review_suggested(core_ok, RuleConfig())
|
|
222
|
+
[]
|
|
223
|
+
>>> # Security-sensitive path - warning
|
|
224
|
+
>>> auth = FileInfo(path="core/auth/login.py", lines=50, is_core=True, source="")
|
|
225
|
+
>>> vs = check_review_suggested(auth, RuleConfig())
|
|
226
|
+
>>> len(vs) == 1 and vs[0].rule == "review_suggested"
|
|
227
|
+
True
|
|
228
|
+
>>> # Low contract ratio - warning
|
|
229
|
+
>>> sym_no = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
230
|
+
>>> low = FileInfo(path="core/calc.py", lines=50, symbols=[sym_no], is_core=True)
|
|
231
|
+
>>> vs = check_review_suggested(low, RuleConfig())
|
|
232
|
+
>>> len(vs) == 1 and "contract" in vs[0].message.lower()
|
|
233
|
+
True
|
|
234
|
+
>>> # Multiple escape hatches - warning
|
|
235
|
+
>>> source = '''
|
|
236
|
+
... # @invar:allow rule1: reason1
|
|
237
|
+
... # @invar:allow rule2: reason2
|
|
238
|
+
... # @invar:allow rule3: reason3
|
|
239
|
+
... '''
|
|
240
|
+
>>> escapes = FileInfo(path="core/complex.py", lines=50, is_core=True, source=source, symbols=[sym])
|
|
241
|
+
>>> vs = check_review_suggested(escapes, RuleConfig())
|
|
242
|
+
>>> len(vs) == 1 and "escape" in vs[0].message.lower()
|
|
243
|
+
True
|
|
244
|
+
"""
|
|
245
|
+
violations: list[Violation] = []
|
|
246
|
+
|
|
247
|
+
# Only check Core files
|
|
248
|
+
if not file_info.is_core:
|
|
249
|
+
return violations
|
|
250
|
+
|
|
251
|
+
# Trigger 1: Security-sensitive path
|
|
252
|
+
if is_security_sensitive(file_info.path):
|
|
253
|
+
violations.append(
|
|
254
|
+
Violation(
|
|
255
|
+
rule="review_suggested",
|
|
256
|
+
severity=Severity.WARNING,
|
|
257
|
+
file=file_info.path,
|
|
258
|
+
line=None,
|
|
259
|
+
message=f"Security-sensitive file: {file_info.path}",
|
|
260
|
+
suggestion="Consider independent /review before task completion",
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
return violations # Only one suggestion per file
|
|
264
|
+
|
|
265
|
+
# Trigger 2: Multiple escape hatches (>= 3)
|
|
266
|
+
source = file_info.source or ""
|
|
267
|
+
escape_count = count_escape_hatches(source)
|
|
268
|
+
if escape_count >= 3:
|
|
269
|
+
violations.append(
|
|
270
|
+
Violation(
|
|
271
|
+
rule="review_suggested",
|
|
272
|
+
severity=Severity.WARNING,
|
|
273
|
+
file=file_info.path,
|
|
274
|
+
line=None,
|
|
275
|
+
message=f"High escape hatch count: {escape_count} @invar:allow markers",
|
|
276
|
+
suggestion="Consider independent /review to validate escape justifications",
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
return violations # Only one suggestion per file
|
|
280
|
+
|
|
281
|
+
# Trigger 3: Low contract ratio (< 50%)
|
|
282
|
+
ratio, total, _ = calculate_contract_ratio(file_info)
|
|
283
|
+
if total > 0 and ratio < 0.5:
|
|
284
|
+
pct = int(ratio * 100)
|
|
285
|
+
violations.append(
|
|
286
|
+
Violation(
|
|
287
|
+
rule="review_suggested",
|
|
288
|
+
severity=Severity.WARNING,
|
|
289
|
+
file=file_info.path,
|
|
290
|
+
line=None,
|
|
291
|
+
message=f"Low contract coverage: {pct}% (threshold: 50%)",
|
|
292
|
+
suggestion="Add contracts, or request independent /review to assess quality",
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
|
|
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,
|
|
@@ -142,11 +150,43 @@ RULE_META: dict[str, RuleMeta] = {
|
|
|
142
150
|
# Shell rules
|
|
143
151
|
"shell_result": RuleMeta(
|
|
144
152
|
name="shell_result",
|
|
145
|
-
severity=Severity.
|
|
153
|
+
severity=Severity.ERROR, # DX-22: Architecture rule, must fix or explain
|
|
146
154
|
category=RuleCategory.SHELL,
|
|
147
155
|
detects="Shell function not returning Result[T, E]",
|
|
148
156
|
cannot_detect=("Result usage correctness", "Error handling quality"),
|
|
149
|
-
hint="Wrap
|
|
157
|
+
hint="Wrap with Success()/Failure(), or add: # @invar:allow shell_result: <reason>",
|
|
158
|
+
),
|
|
159
|
+
"entry_point_too_thick": RuleMeta(
|
|
160
|
+
name="entry_point_too_thick",
|
|
161
|
+
severity=Severity.ERROR, # DX-22: Architecture rule, must fix or explain
|
|
162
|
+
category=RuleCategory.SHELL,
|
|
163
|
+
detects="Entry point (Flask route, Typer command, etc.) exceeds max lines",
|
|
164
|
+
cannot_detect=("Whether complexity is unavoidable", "Framework constraints"),
|
|
165
|
+
hint="Move logic to Shell function, or add: # @invar:allow entry_point_too_thick: <reason>",
|
|
166
|
+
),
|
|
167
|
+
"shell_pure_logic": RuleMeta(
|
|
168
|
+
name="shell_pure_logic",
|
|
169
|
+
severity=Severity.WARNING,
|
|
170
|
+
category=RuleCategory.SHELL,
|
|
171
|
+
detects="Shell function with no I/O operations (pure logic belongs in Core)",
|
|
172
|
+
cannot_detect=("Indirect I/O via method calls", "Framework-specific patterns"),
|
|
173
|
+
hint="Move to Core layer and add @pre/@post contracts, or add: # @shell_orchestration: <reason>",
|
|
174
|
+
),
|
|
175
|
+
"shell_too_complex": RuleMeta(
|
|
176
|
+
name="shell_too_complex",
|
|
177
|
+
severity=Severity.INFO,
|
|
178
|
+
category=RuleCategory.SHELL,
|
|
179
|
+
detects="Shell function with excessive branching complexity",
|
|
180
|
+
cannot_detect=("Whether complexity is justified", "Domain-specific patterns"),
|
|
181
|
+
hint="Extract logic to Core, or add: # @shell_complexity: <reason>",
|
|
182
|
+
),
|
|
183
|
+
"shell_complexity_debt": RuleMeta(
|
|
184
|
+
name="shell_complexity_debt",
|
|
185
|
+
severity=Severity.ERROR,
|
|
186
|
+
category=RuleCategory.SHELL,
|
|
187
|
+
detects="Project accumulated too many unaddressed complexity warnings (DX-22 Fix-or-Explain)",
|
|
188
|
+
cannot_detect=("Individual function justifications",),
|
|
189
|
+
hint="Address shell_too_complex warnings: refactor OR add @shell_complexity: markers",
|
|
150
190
|
),
|
|
151
191
|
# Documentation rules
|
|
152
192
|
"missing_doctest": RuleMeta(
|
|
@@ -157,6 +197,33 @@ RULE_META: dict[str, RuleMeta] = {
|
|
|
157
197
|
cannot_detect=("Doctest quality", "Edge case coverage"),
|
|
158
198
|
hint="Add >>> examples showing typical usage and edge cases",
|
|
159
199
|
),
|
|
200
|
+
# DX-28: Skip abuse prevention
|
|
201
|
+
"skip_without_reason": RuleMeta(
|
|
202
|
+
name="skip_without_reason",
|
|
203
|
+
severity=Severity.WARNING,
|
|
204
|
+
category=RuleCategory.CONTRACTS,
|
|
205
|
+
detects="@skip_property_test used without justification reason",
|
|
206
|
+
cannot_detect=("Whether the reason is valid", "Skip abuse patterns"),
|
|
207
|
+
hint='Add reason: @skip_property_test("category: explanation")',
|
|
208
|
+
),
|
|
209
|
+
# DX-30: Contract coverage ratio
|
|
210
|
+
"contract_quality_ratio": RuleMeta(
|
|
211
|
+
name="contract_quality_ratio",
|
|
212
|
+
severity=Severity.WARNING,
|
|
213
|
+
category=RuleCategory.CONTRACTS,
|
|
214
|
+
detects="Core file with less than 80% contract coverage on public functions",
|
|
215
|
+
cannot_detect=("Contract quality", "Whether contracts are meaningful"),
|
|
216
|
+
hint="Add @pre/@post to public functions. Use Phase TodoList for complex tasks",
|
|
217
|
+
),
|
|
218
|
+
# DX-31: Review suggestion trigger
|
|
219
|
+
"review_suggested": RuleMeta(
|
|
220
|
+
name="review_suggested",
|
|
221
|
+
severity=Severity.WARNING,
|
|
222
|
+
category=RuleCategory.DOCS,
|
|
223
|
+
detects="Conditions warranting independent code review (escape count, coverage, security)",
|
|
224
|
+
cannot_detect=("Whether review was performed", "Review quality"),
|
|
225
|
+
hint="Consider independent /review sub-agent before task completion",
|
|
226
|
+
),
|
|
160
227
|
}
|
|
161
228
|
|
|
162
229
|
|