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.
Files changed (89) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +10 -10
  3. invar/core/entry_points.py +105 -32
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +1 -2
  6. invar/core/formatter.py +6 -7
  7. invar/core/hypothesis_strategies.py +5 -7
  8. invar/core/inspect.py +1 -1
  9. invar/core/lambda_helpers.py +3 -3
  10. invar/core/models.py +7 -1
  11. invar/core/must_use.py +2 -1
  12. invar/core/parser.py +7 -4
  13. invar/core/postcondition_scope.py +128 -0
  14. invar/core/property_gen.py +8 -5
  15. invar/core/purity.py +3 -3
  16. invar/core/purity_heuristics.py +5 -9
  17. invar/core/references.py +8 -6
  18. invar/core/review_trigger.py +78 -6
  19. invar/core/rule_meta.py +8 -0
  20. invar/core/rules.py +18 -19
  21. invar/core/shell_analysis.py +5 -10
  22. invar/core/shell_architecture.py +2 -2
  23. invar/core/strategies.py +7 -14
  24. invar/core/suggestions.py +86 -0
  25. invar/core/sync_helpers.py +238 -0
  26. invar/core/tautology.py +102 -37
  27. invar/core/template_parser.py +467 -0
  28. invar/core/timeout_inference.py +4 -7
  29. invar/core/utils.py +13 -15
  30. invar/core/verification_routing.py +4 -7
  31. invar/mcp/server.py +100 -17
  32. invar/shell/commands/__init__.py +11 -0
  33. invar/shell/{cli.py → commands/guard.py} +94 -14
  34. invar/shell/{init_cmd.py → commands/init.py} +179 -27
  35. invar/shell/commands/merge.py +256 -0
  36. invar/shell/commands/sync_self.py +113 -0
  37. invar/shell/commands/template_sync.py +366 -0
  38. invar/shell/commands/update.py +48 -0
  39. invar/shell/config.py +12 -24
  40. invar/shell/coverage.py +351 -0
  41. invar/shell/guard_helpers.py +38 -17
  42. invar/shell/guard_output.py +7 -1
  43. invar/shell/property_tests.py +58 -22
  44. invar/shell/prove/__init__.py +9 -0
  45. invar/shell/{prove.py → prove/crosshair.py} +40 -33
  46. invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
  47. invar/shell/subprocess_env.py +393 -0
  48. invar/shell/template_engine.py +345 -0
  49. invar/shell/templates.py +19 -0
  50. invar/shell/testing.py +71 -20
  51. invar/templates/CLAUDE.md.template +38 -17
  52. invar/templates/aider.conf.yml.template +2 -2
  53. invar/templates/commands/{review.md → audit.md} +20 -82
  54. invar/templates/commands/guard.md +77 -0
  55. invar/templates/config/CLAUDE.md.jinja +206 -0
  56. invar/templates/config/context.md.jinja +92 -0
  57. invar/templates/config/pre-commit.yaml.jinja +44 -0
  58. invar/templates/context.md.template +33 -0
  59. invar/templates/cursorrules.template +7 -4
  60. invar/templates/examples/README.md +2 -0
  61. invar/templates/examples/conftest.py +3 -0
  62. invar/templates/examples/contracts.py +5 -5
  63. invar/templates/examples/core_shell.py +11 -7
  64. invar/templates/examples/workflow.md +81 -0
  65. invar/templates/manifest.toml +137 -0
  66. invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
  67. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  68. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  69. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  70. invar/templates/skills/review/SKILL.md.jinja +125 -0
  71. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
  72. invar_tools-1.3.0.dist-info/RECORD +95 -0
  73. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  74. invar/contracts.py +0 -152
  75. invar/decorators.py +0 -94
  76. invar/invariant.py +0 -58
  77. invar/resource.py +0 -99
  78. invar/shell/update_cmd.py +0 -193
  79. invar_tools-1.2.0.dist-info/RECORD +0 -77
  80. invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
  81. /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
  82. /invar/shell/{perception.py → commands/perception.py} +0 -0
  83. /invar/shell/{test_cmd.py → commands/test.py} +0 -0
  84. /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
  85. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  86. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
  87. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
  88. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
  89. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,467 @@
1
+ """DX-49: Pure template parsing logic for region markers.
2
+
3
+ This module provides pure functions for parsing and reconstructing
4
+ files with Invar region markers (<!--invar:name-->...<!--/invar:name-->).
5
+
6
+ All functions are pure (no I/O) with @pre/@post contracts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+
14
+ from deal import ensure, post, pre
15
+
16
+ # =============================================================================
17
+ # Data Models
18
+ # =============================================================================
19
+
20
+
21
+ @dataclass
22
+ class Region:
23
+ """A parsed region from a file with Invar markers.
24
+
25
+ Examples:
26
+ >>> r = Region(name="managed", start=0, end=50, content="# Header")
27
+ >>> r.name
28
+ 'managed'
29
+ >>> r.content
30
+ '# Header'
31
+ """
32
+
33
+ name: str
34
+ start: int
35
+ end: int
36
+ content: str
37
+ version: str = ""
38
+
39
+
40
+ @dataclass
41
+ class ParsedFile:
42
+ """Result of parsing a file with Invar region markers.
43
+
44
+ Examples:
45
+ >>> pf = ParsedFile(regions={}, before="", after="", raw="")
46
+ >>> pf.has_regions
47
+ False
48
+ """
49
+
50
+ regions: dict[str, Region] = field(default_factory=dict)
51
+ before: str = "" # Content before first marker
52
+ after: str = "" # Content after last marker
53
+ raw: str = "" # Original content
54
+
55
+ @property
56
+ # @invar:allow missing_contract: Boolean derived from dict length
57
+ def has_regions(self) -> bool:
58
+ """Check if any Invar regions were found.
59
+
60
+ Examples:
61
+ >>> ParsedFile(regions={"a": Region("a", 0, 10, "")}).has_regions
62
+ True
63
+ >>> ParsedFile(regions={}).has_regions
64
+ False
65
+ """
66
+ return len(self.regions) > 0
67
+
68
+
69
+ # =============================================================================
70
+ # Region Patterns
71
+ # =============================================================================
72
+
73
+ # Patterns for region markers
74
+ # <!--invar:managed version="5.0"-->
75
+ # <!--/invar:managed-->
76
+ REGION_START_PATTERN = re.compile(
77
+ r'<!--invar:(\w+)(?:\s+version=["\']([^"\']+)["\'])?-->'
78
+ )
79
+ REGION_END_PATTERN = re.compile(r"<!--/invar:(\w+)-->")
80
+
81
+
82
+ # =============================================================================
83
+ # Pure Parsing Functions
84
+ # =============================================================================
85
+
86
+
87
+ @post(lambda result: result.raw is not None) # Always captures original content
88
+ @ensure(lambda content, result: result.raw == content) # Preserves input verbatim
89
+ def parse_invar_regions(content: str) -> ParsedFile:
90
+ """Parse <!--invar:...--> regions from content.
91
+
92
+ Extracts named regions while preserving content before/after markers.
93
+
94
+ Examples:
95
+ >>> content = '''before
96
+ ... <!--invar:managed-->
97
+ ... managed content
98
+ ... <!--/invar:managed-->
99
+ ... after'''
100
+ >>> parsed = parse_invar_regions(content)
101
+ >>> parsed.has_regions
102
+ True
103
+ >>> "managed" in parsed.regions
104
+ True
105
+ >>> parsed.regions["managed"].content.strip()
106
+ 'managed content'
107
+ >>> "before" in parsed.before
108
+ True
109
+ >>> "after" in parsed.after
110
+ True
111
+
112
+ >>> # No regions
113
+ >>> parsed2 = parse_invar_regions("just plain text")
114
+ >>> parsed2.has_regions
115
+ False
116
+ >>> parsed2.before
117
+ 'just plain text'
118
+ """
119
+ if "<!--invar:" not in content:
120
+ return ParsedFile(raw=content, before=content)
121
+
122
+ regions: dict[str, Region] = {}
123
+ before = ""
124
+ after = ""
125
+ last_end = 0
126
+ first_start: int | None = None
127
+
128
+ # Find all region starts
129
+ for start_match in REGION_START_PATTERN.finditer(content):
130
+ region_name = start_match.group(1)
131
+ version = start_match.group(2) or ""
132
+ region_start = start_match.start()
133
+
134
+ if first_start is None:
135
+ first_start = region_start
136
+ before = content[:region_start]
137
+
138
+ # Find corresponding end marker
139
+ end_pattern = re.compile(rf"<!--/invar:{region_name}-->")
140
+ end_match = end_pattern.search(content, start_match.end())
141
+
142
+ if end_match:
143
+ region_content = content[start_match.end() : end_match.start()]
144
+ regions[region_name] = Region(
145
+ name=region_name,
146
+ start=region_start,
147
+ end=end_match.end(),
148
+ content=region_content,
149
+ version=version,
150
+ )
151
+ last_end = end_match.end()
152
+
153
+ # Content after last region
154
+ if last_end > 0:
155
+ after = content[last_end:]
156
+
157
+ return ParsedFile(regions=regions, before=before, after=after, raw=content)
158
+
159
+
160
+ @pre(lambda parsed, updates: all(k == v.name for k, v in parsed.regions.items())) # Keys must match names
161
+ @ensure(lambda parsed, updates, result: (
162
+ not parsed.has_regions or all(f"<!--invar:{r}" in result for r in parsed.regions)
163
+ )) # Checks start tag prefix (version attribute may follow)
164
+ def reconstruct_file(parsed: ParsedFile, updates: dict[str, str]) -> str:
165
+ """Reconstruct file content with updated regions.
166
+
167
+ Preserves:
168
+ - Content before first marker
169
+ - Content after last marker
170
+ - Regions not in updates dict
171
+
172
+ Note:
173
+ Regions must be contiguous (no content between region end and next start).
174
+ Content between regions is NOT preserved. This matches Invar's template
175
+ design where regions are adjacent.
176
+
177
+ Examples:
178
+ >>> content = '''before
179
+ ... <!--invar:managed-->
180
+ ... old content
181
+ ... <!--/invar:managed-->
182
+ ... <!--invar:user-->
183
+ ... user content
184
+ ... <!--/invar:user-->
185
+ ... after'''
186
+ >>> parsed = parse_invar_regions(content)
187
+ >>> result = reconstruct_file(parsed, {"managed": "NEW CONTENT"})
188
+ >>> "NEW CONTENT" in result
189
+ True
190
+ >>> "user content" in result
191
+ True
192
+ >>> "before" in result
193
+ True
194
+ >>> "after" in result
195
+ True
196
+ """
197
+ if not parsed.has_regions:
198
+ # No regions - return original content
199
+ return parsed.raw
200
+
201
+ parts = [parsed.before]
202
+
203
+ # Sort regions by their original position
204
+ sorted_regions = sorted(parsed.regions.values(), key=lambda r: r.start)
205
+
206
+ for region in sorted_regions:
207
+ # Start marker
208
+ if region.version:
209
+ parts.append(f'<!--invar:{region.name} version="{region.version}"-->')
210
+ else:
211
+ parts.append(f"<!--invar:{region.name}-->")
212
+
213
+ # Content - updated or original
214
+ if region.name in updates:
215
+ content = updates[region.name]
216
+ # Ensure content has newlines at boundaries
217
+ if content and not content.startswith("\n"):
218
+ content = "\n" + content
219
+ if content and not content.endswith("\n"):
220
+ content = content + "\n"
221
+ parts.append(content)
222
+ else:
223
+ parts.append(region.content)
224
+
225
+ # End marker
226
+ parts.append(f"<!--/invar:{region.name}-->")
227
+
228
+ parts.append(parsed.after)
229
+
230
+ return "".join(parts)
231
+
232
+
233
+ @post(lambda result: len(result) > 0) # Always returns a syntax variant (defaults to "cli")
234
+ def get_syntax_for_command(command: str, manifest: dict) -> str:
235
+ """Get the syntax variant for a command.
236
+
237
+ Examples:
238
+ >>> manifest = {"commands": {"init": {"syntax": "cli"}}}
239
+ >>> get_syntax_for_command("init", manifest)
240
+ 'cli'
241
+ >>> get_syntax_for_command("unknown", manifest)
242
+ 'cli'
243
+ """
244
+ commands = manifest.get("commands", {})
245
+ cmd_config = commands.get(command, {})
246
+ return cmd_config.get("syntax", "cli")
247
+
248
+
249
+ # =============================================================================
250
+ # DX-55: State Detection for Idempotent Init
251
+ # =============================================================================
252
+
253
+
254
+ @dataclass
255
+ class ClaudeMdState:
256
+ """State of CLAUDE.md Invar regions.
257
+
258
+ Examples:
259
+ >>> state = ClaudeMdState(state="intact", has_managed=True, has_user=True)
260
+ >>> state.state
261
+ 'intact'
262
+ >>> state.needs_recovery
263
+ False
264
+ >>> partial = ClaudeMdState(state="partial", has_managed=True, has_user=False)
265
+ >>> partial.needs_recovery
266
+ True
267
+ """
268
+
269
+ state: str # "intact", "partial", "missing", "absent"
270
+ has_managed: bool = False
271
+ has_user: bool = False
272
+ has_project: bool = False
273
+ version: str = ""
274
+ user_content: str = "" # Preserved user content
275
+
276
+ @property
277
+ # @invar:allow missing_contract: Boolean derived from state enum check
278
+ def needs_recovery(self) -> bool:
279
+ """Check if recovery/merge is needed.
280
+
281
+ Examples:
282
+ >>> ClaudeMdState(state="intact").needs_recovery
283
+ False
284
+ >>> ClaudeMdState(state="missing").needs_recovery
285
+ True
286
+ """
287
+ return self.state in ("partial", "missing")
288
+
289
+
290
+ @post(lambda result: result.state in ("intact", "partial", "missing", "absent"))
291
+ def detect_claude_md_state(content: str) -> ClaudeMdState:
292
+ """Detect the state of CLAUDE.md Invar regions.
293
+
294
+ DX-55: Core state detection for idempotent init.
295
+
296
+ States:
297
+ - "intact": All required regions present and properly closed
298
+ - "partial": Some regions present but malformed (corruption)
299
+ - "missing": File exists but no Invar regions (overwritten by claude /init)
300
+ - "absent": Empty content (file doesn't exist - caller handles this)
301
+
302
+ Examples:
303
+ >>> # Intact state
304
+ >>> intact = '''<!--invar:managed version="5.0"-->
305
+ ... managed content
306
+ ... <!--/invar:managed--><!--invar:project-->
307
+ ... <!--/invar:project--><!--invar:user-->
308
+ ... user content
309
+ ... <!--/invar:user-->'''
310
+ >>> state = detect_claude_md_state(intact)
311
+ >>> state.state
312
+ 'intact'
313
+ >>> state.has_managed
314
+ True
315
+ >>> state.has_user
316
+ True
317
+ >>> "user content" in state.user_content
318
+ True
319
+
320
+ >>> # Missing state (no Invar markers)
321
+ >>> missing = "# Project Guide\\nGenerated by Claude"
322
+ >>> state2 = detect_claude_md_state(missing)
323
+ >>> state2.state
324
+ 'missing'
325
+ >>> state2.has_managed
326
+ False
327
+
328
+ >>> # Partial state (incomplete markers)
329
+ >>> partial = "<!--invar:managed-->content but no close tag"
330
+ >>> state3 = detect_claude_md_state(partial)
331
+ >>> state3.state
332
+ 'partial'
333
+
334
+ >>> # Absent state (empty)
335
+ >>> state4 = detect_claude_md_state("")
336
+ >>> state4.state
337
+ 'absent'
338
+ """
339
+ if not content.strip():
340
+ return ClaudeMdState(state="absent")
341
+
342
+ # Check for markers
343
+ has_managed_open = "<!--invar:managed" in content
344
+ has_managed_close = "<!--/invar:managed-->" in content
345
+ has_user_open = "<!--invar:user-->" in content
346
+ has_user_close = "<!--/invar:user-->" in content
347
+ has_project_open = "<!--invar:project-->" in content
348
+ has_project_close = "<!--/invar:project-->" in content
349
+
350
+ # Extract version if present
351
+ version = ""
352
+ version_match = re.search(r'<!--invar:managed\s+version=["\']([^"\']+)["\']-->', content)
353
+ if version_match:
354
+ version = version_match.group(1)
355
+
356
+ # Determine state
357
+ managed_complete = has_managed_open and has_managed_close
358
+ user_complete = has_user_open and has_user_close
359
+ project_complete = has_project_open and has_project_close
360
+
361
+ # All markers present
362
+ any_marker = any([
363
+ has_managed_open, has_managed_close,
364
+ has_user_open, has_user_close,
365
+ has_project_open, has_project_close,
366
+ ])
367
+
368
+ if not any_marker:
369
+ return ClaudeMdState(state="missing")
370
+
371
+ # Extract user content if user region is complete
372
+ user_content = ""
373
+ if user_complete:
374
+ parsed = parse_invar_regions(content)
375
+ if "user" in parsed.regions:
376
+ user_content = parsed.regions["user"].content
377
+
378
+ # Check if all required regions are complete
379
+ if managed_complete and user_complete:
380
+ return ClaudeMdState(
381
+ state="intact",
382
+ has_managed=True,
383
+ has_user=True,
384
+ has_project=project_complete,
385
+ version=version,
386
+ user_content=user_content,
387
+ )
388
+
389
+ # Some markers but not all complete - partial corruption
390
+ return ClaudeMdState(
391
+ state="partial",
392
+ has_managed=managed_complete,
393
+ has_user=user_complete,
394
+ has_project=project_complete,
395
+ version=version,
396
+ user_content=user_content,
397
+ )
398
+
399
+
400
+ @post(lambda result: "<!--invar" not in result) # All markers removed
401
+ def strip_invar_markers(content: str) -> str:
402
+ """Remove all Invar region markers, keeping content.
403
+
404
+ DX-55: Used for recovering content from corrupted files.
405
+
406
+ Examples:
407
+ >>> content = '''<!--invar:managed-->
408
+ ... managed content
409
+ ... <!--/invar:managed-->
410
+ ... <!--invar:user-->
411
+ ... user content
412
+ ... <!--/invar:user-->'''
413
+ >>> cleaned = strip_invar_markers(content)
414
+ >>> "<!--invar" in cleaned
415
+ False
416
+ >>> "managed content" in cleaned
417
+ True
418
+ >>> "user content" in cleaned
419
+ True
420
+
421
+ >>> # No markers
422
+ >>> strip_invar_markers("plain text")
423
+ 'plain text'
424
+ """
425
+ # Remove all <!--invar:xxx--> and <!--/invar:xxx--> markers
426
+ # Also handle version attribute
427
+ cleaned = re.sub(r'<!--/?invar:\w+[^>]*-->', '', content)
428
+ # Clean up excessive blank lines
429
+ cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
430
+ return cleaned.strip()
431
+
432
+
433
+ @pre(lambda content, merge_date: len(content) > 0)
434
+ @post(lambda result: "MERGED CONTENT" in result)
435
+ def format_preserved_content(content: str, merge_date: str = "") -> str:
436
+ """Format preserved content with review markers.
437
+
438
+ DX-55: Wraps content that was overwritten/corrupted for user review.
439
+
440
+ Args:
441
+ content: The content to preserve
442
+ merge_date: ISO format date string (shell provides this)
443
+
444
+ Examples:
445
+ >>> content = "# Project Guide\\nSome analysis"
446
+ >>> formatted = format_preserved_content(content, "2025-12-27")
447
+ >>> "MERGED CONTENT" in formatted
448
+ True
449
+ >>> "Project Guide" in formatted
450
+ True
451
+ >>> "2025-12-27" in formatted
452
+ True
453
+ """
454
+ date_line = f"<!-- Merge date: {merge_date} -->\n" if merge_date else ""
455
+
456
+ return f"""<!-- ======================================== -->
457
+ <!-- MERGED CONTENT - Please review and organize -->
458
+ <!-- Original source: claude /init or manual edit -->
459
+ {date_line}<!-- ======================================== -->
460
+
461
+ ## Claude Analysis (Preserved)
462
+
463
+ {content}
464
+
465
+ <!-- ======================================== -->
466
+ <!-- END MERGED CONTENT -->
467
+ <!-- ======================================== -->"""
@@ -42,7 +42,7 @@ LIBRARY_BLACKLIST = frozenset([
42
42
 
43
43
 
44
44
  @pre(lambda func: callable(func))
45
- @post(lambda result: isinstance(result, int) and result > 0)
45
+ @post(lambda result: result > 0) # Timeout must be positive
46
46
  def infer_timeout(func: Callable) -> int:
47
47
  """
48
48
  Infer appropriate CrossHair timeout from function source.
@@ -80,8 +80,7 @@ def infer_timeout(func: Callable) -> int:
80
80
  return TIMEOUT_TIERS["pure_python"].timeout
81
81
 
82
82
 
83
- @pre(lambda source: isinstance(source, str))
84
- @post(lambda result: isinstance(result, int) and result >= 0)
83
+ @post(lambda result: result >= 0) # Nesting depth is non-negative
85
84
  def _estimate_nesting_depth(source: str) -> int:
86
85
  """Estimate maximum nesting depth from indentation."""
87
86
  max_indent = 0
@@ -94,15 +93,13 @@ def _estimate_nesting_depth(source: str) -> int:
94
93
  return max_indent
95
94
 
96
95
 
97
- @pre(lambda source: isinstance(source, str))
98
- @post(lambda result: isinstance(result, int) and result >= 0)
96
+ @post(lambda result: result >= 0) # Branch count is non-negative
99
97
  def _count_branches(source: str) -> int:
100
98
  """Count branching statements (if, for, while, try)."""
101
99
  return len(re.findall(r"\b(if|for|while|try|elif|except)\b", source))
102
100
 
103
101
 
104
- @pre(lambda source: isinstance(source, str))
105
- @post(lambda result: isinstance(result, bool))
102
+ # @invar:allow missing_contract: Boolean predicate, empty string is valid input
106
103
  def _uses_only_stdlib(source: str) -> bool:
107
104
  """Check if source only uses standard library."""
108
105
  stdlib_patterns = ["collections", "itertools", "functools", "typing", "dataclasses"]
invar/core/utils.py CHANGED
@@ -15,7 +15,7 @@ from deal import post, pre
15
15
  from invar.core.models import GuardReport, RuleConfig, RuleExclusion
16
16
 
17
17
 
18
- @pre(lambda report, strict: isinstance(report, GuardReport))
18
+ @pre(lambda report, strict: report.files_checked >= 0 and report.errors >= 0)
19
19
  @post(lambda result: result in (0, 1))
20
20
  def get_exit_code(report: GuardReport, strict: bool) -> int:
21
21
  """
@@ -37,7 +37,7 @@ def get_exit_code(report: GuardReport, strict: bool) -> int:
37
37
  return 0
38
38
 
39
39
 
40
- @pre(lambda report, strict, doctest_passed=True, crosshair_passed=True, property_passed=True: isinstance(report, GuardReport))
40
+ @pre(lambda report, strict, doctest_passed=True, crosshair_passed=True, property_passed=True: report.files_checked >= 0)
41
41
  @post(lambda result: result in ("passed", "failed"))
42
42
  def get_combined_status(
43
43
  report: GuardReport,
@@ -83,7 +83,7 @@ def get_combined_status(
83
83
  return "passed"
84
84
 
85
85
 
86
- @pre(lambda data, source: isinstance(data, dict) and isinstance(source, str))
86
+ @pre(lambda data, source: source in ("pyproject", "invar", "default"))
87
87
  @post(lambda result: isinstance(result, dict))
88
88
  def extract_guard_section(data: dict[str, Any], source: str) -> dict[str, Any]:
89
89
  """
@@ -113,7 +113,7 @@ def extract_guard_section(data: dict[str, Any], source: str) -> dict[str, Any]:
113
113
  return result if isinstance(result, dict) else {}
114
114
 
115
115
 
116
- @pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
116
+ @pre(lambda config, key: len(key) > 0)
117
117
  @post(lambda result: result is None or isinstance(result, bool))
118
118
  def _get_bool(config: dict[str, Any], key: str) -> bool | None:
119
119
  """
@@ -130,7 +130,7 @@ def _get_bool(config: dict[str, Any], key: str) -> bool | None:
130
130
  return None
131
131
 
132
132
 
133
- @pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
133
+ @pre(lambda config, key: len(key) > 0)
134
134
  @post(lambda result: result is None or isinstance(result, int))
135
135
  def _get_int(config: dict[str, Any], key: str) -> int | None:
136
136
  """
@@ -147,7 +147,7 @@ def _get_int(config: dict[str, Any], key: str) -> int | None:
147
147
  return None
148
148
 
149
149
 
150
- @pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
150
+ @pre(lambda config, key: len(key) > 0)
151
151
  @post(lambda result: result is None or isinstance(result, float))
152
152
  def _get_float(config: dict[str, Any], key: str) -> float | None:
153
153
  """
@@ -166,7 +166,7 @@ def _get_float(config: dict[str, Any], key: str) -> float | None:
166
166
  return None
167
167
 
168
168
 
169
- @pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
169
+ @pre(lambda config, key: len(key) > 0)
170
170
  @post(lambda result: result is None or isinstance(result, list))
171
171
  def _get_str_list(config: dict[str, Any], key: str) -> list[str] | None:
172
172
  """
@@ -183,7 +183,6 @@ def _get_str_list(config: dict[str, Any], key: str) -> list[str] | None:
183
183
  return None
184
184
 
185
185
 
186
- @pre(lambda config: isinstance(config, dict))
187
186
  @post(lambda result: result is None or isinstance(result, list))
188
187
  def _parse_rule_exclusions(config: dict[str, Any]) -> list[RuleExclusion] | None:
189
188
  """
@@ -207,28 +206,27 @@ def _parse_rule_exclusions(config: dict[str, Any]) -> list[RuleExclusion] | None
207
206
  return exclusions if exclusions else None
208
207
 
209
208
 
210
- @pre(lambda config: isinstance(config, dict))
211
209
  @post(lambda result: result is None or isinstance(result, dict))
212
210
  def _parse_severity_overrides(config: dict[str, Any]) -> dict[str, str] | None:
213
211
  """
214
212
  Parse severity_overrides from config (merge with defaults).
215
213
 
216
214
  >>> _parse_severity_overrides({"severity_overrides": {"foo": "off"}})
217
- {'redundant_type_contract': 'off', 'foo': 'off'}
215
+ {'redundant_type_contract': 'warning', 'foo': 'off'}
218
216
  >>> _parse_severity_overrides({}) is None
219
217
  True
220
218
  """
221
219
  raw = config.get("severity_overrides")
222
220
  if not isinstance(raw, dict):
223
221
  return None
224
- defaults: dict[str, str] = {"redundant_type_contract": "off"}
222
+ # DX-38 Tier 2: redundant_type_contract enabled by default
223
+ defaults: dict[str, str] = {"redundant_type_contract": "warning"}
225
224
  for k, v in raw.items():
226
225
  if isinstance(k, str) and isinstance(v, str):
227
226
  defaults[str(k)] = str(v)
228
227
  return defaults
229
228
 
230
229
 
231
- @pre(lambda guard_config: isinstance(guard_config, dict))
232
230
  @post(lambda result: isinstance(result, RuleConfig))
233
231
  def parse_guard_config(guard_config: dict[str, Any]) -> RuleConfig:
234
232
  """
@@ -286,7 +284,7 @@ def parse_guard_config(guard_config: dict[str, Any]) -> RuleConfig:
286
284
  return RuleConfig()
287
285
 
288
286
 
289
- @pre(lambda file_path, patterns: isinstance(file_path, str) and isinstance(patterns, list))
287
+ @pre(lambda file_path, patterns: len(file_path) > 0)
290
288
  def matches_pattern(file_path: str, patterns: list[str]) -> bool:
291
289
  """
292
290
  Check if a file path matches any of the glob patterns.
@@ -316,7 +314,7 @@ def matches_pattern(file_path: str, patterns: list[str]) -> bool:
316
314
  return False
317
315
 
318
316
 
319
- @pre(lambda file_path, prefixes: isinstance(file_path, str) and isinstance(prefixes, list))
317
+ @pre(lambda file_path, prefixes: len(file_path) > 0)
320
318
  def matches_path_prefix(file_path: str, prefixes: list[str]) -> bool:
321
319
  """
322
320
  Check if file_path starts with any of the given prefixes.
@@ -386,7 +384,7 @@ def match_glob_pattern(file_path: str, pattern: str) -> bool:
386
384
  return False
387
385
 
388
386
 
389
- @pre(lambda file_path, config: isinstance(config, RuleConfig))
387
+ @pre(lambda file_path, config: len(file_path) > 0)
390
388
  def get_excluded_rules(file_path: str, config: RuleConfig) -> set[str]:
391
389
  """
392
390
  Get the set of rules to exclude for a given file path.
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import re
11
11
  from enum import Enum
12
12
 
13
- from deal import post, pre
13
+ from deal import post
14
14
 
15
15
 
16
16
  class VerificationTool(Enum):
@@ -64,8 +64,7 @@ _IMPORT_PATTERN = re.compile(
64
64
  )
65
65
 
66
66
 
67
- @pre(lambda source: isinstance(source, str))
68
- @post(lambda result: isinstance(result, bool))
67
+ # @invar:allow missing_contract: Boolean predicate, empty string returns False
69
68
  def has_incompatible_imports(source: str) -> bool:
70
69
  """
71
70
  Check if source contains imports incompatible with CrossHair.
@@ -99,8 +98,7 @@ def has_incompatible_imports(source: str) -> bool:
99
98
  return False
100
99
 
101
100
 
102
- @pre(lambda source: isinstance(source, str))
103
- @post(lambda result: isinstance(result, set))
101
+ @post(lambda result: all(lib in CROSSHAIR_INCOMPATIBLE_LIBS for lib in result))
104
102
  def get_incompatible_imports(source: str) -> set[str]:
105
103
  """
106
104
  Get the set of incompatible libraries imported in source.
@@ -126,8 +124,7 @@ def get_incompatible_imports(source: str) -> set[str]:
126
124
  return incompatible
127
125
 
128
126
 
129
- @pre(lambda source, has_contracts: isinstance(source, str) and isinstance(has_contracts, bool))
130
- @post(lambda result: isinstance(result, VerificationTool))
127
+ @post(lambda result: result in VerificationTool) # Returns valid enum member
131
128
  def select_verification_tool(source: str, has_contracts: bool) -> VerificationTool:
132
129
  """
133
130
  Select the appropriate verification tool for a source file.