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.
Files changed (98) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +80 -10
  3. invar/core/entry_points.py +367 -0
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +195 -0
  6. invar/core/format_strategies.py +197 -0
  7. invar/core/formatter.py +32 -10
  8. invar/core/hypothesis_strategies.py +50 -10
  9. invar/core/inspect.py +1 -1
  10. invar/core/lambda_helpers.py +3 -2
  11. invar/core/models.py +30 -18
  12. invar/core/must_use.py +2 -1
  13. invar/core/parser.py +13 -6
  14. invar/core/postcondition_scope.py +128 -0
  15. invar/core/property_gen.py +86 -42
  16. invar/core/purity.py +13 -7
  17. invar/core/purity_heuristics.py +5 -9
  18. invar/core/references.py +8 -6
  19. invar/core/review_trigger.py +370 -0
  20. invar/core/rule_meta.py +69 -2
  21. invar/core/rules.py +91 -28
  22. invar/core/shell_analysis.py +247 -0
  23. invar/core/shell_architecture.py +171 -0
  24. invar/core/strategies.py +7 -14
  25. invar/core/suggestions.py +92 -0
  26. invar/core/sync_helpers.py +238 -0
  27. invar/core/tautology.py +103 -37
  28. invar/core/template_parser.py +467 -0
  29. invar/core/timeout_inference.py +4 -7
  30. invar/core/utils.py +63 -18
  31. invar/core/verification_routing.py +155 -0
  32. invar/mcp/server.py +113 -13
  33. invar/shell/commands/__init__.py +11 -0
  34. invar/shell/{cli.py → commands/guard.py} +152 -44
  35. invar/shell/{init_cmd.py → commands/init.py} +200 -28
  36. invar/shell/commands/merge.py +256 -0
  37. invar/shell/commands/mutate.py +184 -0
  38. invar/shell/{perception.py → commands/perception.py} +2 -0
  39. invar/shell/commands/sync_self.py +113 -0
  40. invar/shell/commands/template_sync.py +366 -0
  41. invar/shell/{test_cmd.py → commands/test.py} +3 -1
  42. invar/shell/commands/update.py +48 -0
  43. invar/shell/config.py +247 -10
  44. invar/shell/coverage.py +351 -0
  45. invar/shell/fs.py +5 -2
  46. invar/shell/git.py +2 -0
  47. invar/shell/guard_helpers.py +116 -20
  48. invar/shell/guard_output.py +106 -24
  49. invar/shell/mcp_config.py +3 -0
  50. invar/shell/mutation.py +314 -0
  51. invar/shell/property_tests.py +75 -24
  52. invar/shell/prove/__init__.py +9 -0
  53. invar/shell/prove/accept.py +113 -0
  54. invar/shell/{prove.py → prove/crosshair.py} +69 -30
  55. invar/shell/prove/hypothesis.py +293 -0
  56. invar/shell/subprocess_env.py +393 -0
  57. invar/shell/template_engine.py +345 -0
  58. invar/shell/templates.py +53 -0
  59. invar/shell/testing.py +77 -37
  60. invar/templates/CLAUDE.md.template +86 -9
  61. invar/templates/aider.conf.yml.template +16 -14
  62. invar/templates/commands/audit.md +138 -0
  63. invar/templates/commands/guard.md +77 -0
  64. invar/templates/config/CLAUDE.md.jinja +206 -0
  65. invar/templates/config/context.md.jinja +92 -0
  66. invar/templates/config/pre-commit.yaml.jinja +44 -0
  67. invar/templates/context.md.template +33 -0
  68. invar/templates/cursorrules.template +25 -13
  69. invar/templates/examples/README.md +2 -0
  70. invar/templates/examples/conftest.py +3 -0
  71. invar/templates/examples/contracts.py +4 -2
  72. invar/templates/examples/core_shell.py +10 -4
  73. invar/templates/examples/workflow.md +81 -0
  74. invar/templates/manifest.toml +137 -0
  75. invar/templates/protocol/INVAR.md +210 -0
  76. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  77. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  78. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  79. invar/templates/skills/review/SKILL.md.jinja +125 -0
  80. invar_tools-1.3.0.dist-info/METADATA +377 -0
  81. invar_tools-1.3.0.dist-info/RECORD +95 -0
  82. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  83. invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
  84. invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
  85. invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
  86. invar/contracts.py +0 -152
  87. invar/decorators.py +0 -94
  88. invar/invariant.py +0 -57
  89. invar/resource.py +0 -99
  90. invar/shell/prove_fallback.py +0 -183
  91. invar/shell/update_cmd.py +0 -191
  92. invar/templates/INVAR.md +0 -134
  93. invar_tools-1.0.0.dist-info/METADATA +0 -321
  94. invar_tools-1.0.0.dist-info/RECORD +0 -64
  95. invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
  96. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  97. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  98. {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.WARNING,
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 return value with Success() or return Failure()",
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