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,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,53 @@ def get_exit_code(report: GuardReport, strict: bool) -> int:
37
37
  return 0
38
38
 
39
39
 
40
- @pre(lambda data, source: isinstance(data, dict) and isinstance(source, str))
40
+ @pre(lambda report, strict, doctest_passed=True, crosshair_passed=True, property_passed=True: report.files_checked >= 0)
41
+ @post(lambda result: result in ("passed", "failed"))
42
+ def get_combined_status(
43
+ report: GuardReport,
44
+ strict: bool,
45
+ doctest_passed: bool = True,
46
+ crosshair_passed: bool = True,
47
+ property_passed: bool = True,
48
+ ) -> str:
49
+ """
50
+ Calculate true guard status including all test phases (DX-26).
51
+
52
+ Unlike GuardReport.passed which only checks static errors,
53
+ this function combines static analysis with runtime test results.
54
+
55
+ Examples:
56
+ >>> from invar.core.models import GuardReport
57
+ >>> report = GuardReport(files_checked=1)
58
+ >>> get_combined_status(report, strict=False)
59
+ 'passed'
60
+ >>> get_combined_status(report, strict=False, doctest_passed=False)
61
+ 'failed'
62
+ >>> report.errors = 1
63
+ >>> get_combined_status(report, strict=False)
64
+ 'failed'
65
+ >>> report2 = GuardReport(files_checked=1, warnings=1)
66
+ >>> get_combined_status(report2, strict=True)
67
+ 'failed'
68
+ >>> get_combined_status(report2, strict=False)
69
+ 'passed'
70
+ """
71
+ # Static analysis failures
72
+ if report.errors > 0:
73
+ return "failed"
74
+ if strict and report.warnings > 0:
75
+ return "failed"
76
+ # Runtime test failures
77
+ if not doctest_passed:
78
+ return "failed"
79
+ if not crosshair_passed:
80
+ return "failed"
81
+ if not property_passed:
82
+ return "failed"
83
+ return "passed"
84
+
85
+
86
+ @pre(lambda data, source: source in ("pyproject", "invar", "default"))
41
87
  @post(lambda result: isinstance(result, dict))
42
88
  def extract_guard_section(data: dict[str, Any], source: str) -> dict[str, Any]:
43
89
  """
@@ -67,7 +113,7 @@ def extract_guard_section(data: dict[str, Any], source: str) -> dict[str, Any]:
67
113
  return result if isinstance(result, dict) else {}
68
114
 
69
115
 
70
- @pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
116
+ @pre(lambda config, key: len(key) > 0)
71
117
  @post(lambda result: result is None or isinstance(result, bool))
72
118
  def _get_bool(config: dict[str, Any], key: str) -> bool | None:
73
119
  """
@@ -84,7 +130,7 @@ def _get_bool(config: dict[str, Any], key: str) -> bool | None:
84
130
  return None
85
131
 
86
132
 
87
- @pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
133
+ @pre(lambda config, key: len(key) > 0)
88
134
  @post(lambda result: result is None or isinstance(result, int))
89
135
  def _get_int(config: dict[str, Any], key: str) -> int | None:
90
136
  """
@@ -101,7 +147,7 @@ def _get_int(config: dict[str, Any], key: str) -> int | None:
101
147
  return None
102
148
 
103
149
 
104
- @pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
150
+ @pre(lambda config, key: len(key) > 0)
105
151
  @post(lambda result: result is None or isinstance(result, float))
106
152
  def _get_float(config: dict[str, Any], key: str) -> float | None:
107
153
  """
@@ -120,7 +166,7 @@ def _get_float(config: dict[str, Any], key: str) -> float | None:
120
166
  return None
121
167
 
122
168
 
123
- @pre(lambda config, key: isinstance(config, dict) and isinstance(key, str))
169
+ @pre(lambda config, key: len(key) > 0)
124
170
  @post(lambda result: result is None or isinstance(result, list))
125
171
  def _get_str_list(config: dict[str, Any], key: str) -> list[str] | None:
126
172
  """
@@ -137,7 +183,6 @@ def _get_str_list(config: dict[str, Any], key: str) -> list[str] | None:
137
183
  return None
138
184
 
139
185
 
140
- @pre(lambda config: isinstance(config, dict))
141
186
  @post(lambda result: result is None or isinstance(result, list))
142
187
  def _parse_rule_exclusions(config: dict[str, Any]) -> list[RuleExclusion] | None:
143
188
  """
@@ -161,33 +206,35 @@ def _parse_rule_exclusions(config: dict[str, Any]) -> list[RuleExclusion] | None
161
206
  return exclusions if exclusions else None
162
207
 
163
208
 
164
- @pre(lambda config: isinstance(config, dict))
165
209
  @post(lambda result: result is None or isinstance(result, dict))
166
210
  def _parse_severity_overrides(config: dict[str, Any]) -> dict[str, str] | None:
167
211
  """
168
212
  Parse severity_overrides from config (merge with defaults).
169
213
 
170
214
  >>> _parse_severity_overrides({"severity_overrides": {"foo": "off"}})
171
- {'redundant_type_contract': 'off', 'foo': 'off'}
215
+ {'redundant_type_contract': 'warning', 'foo': 'off'}
172
216
  >>> _parse_severity_overrides({}) is None
173
217
  True
174
218
  """
175
219
  raw = config.get("severity_overrides")
176
220
  if not isinstance(raw, dict):
177
221
  return None
178
- 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"}
179
224
  for k, v in raw.items():
180
225
  if isinstance(k, str) and isinstance(v, str):
181
226
  defaults[str(k)] = str(v)
182
227
  return defaults
183
228
 
184
229
 
185
- @pre(lambda guard_config: isinstance(guard_config, dict))
186
230
  @post(lambda result: isinstance(result, RuleConfig))
187
231
  def parse_guard_config(guard_config: dict[str, Any]) -> RuleConfig:
188
232
  """
189
233
  Parse configuration from guard section.
190
234
 
235
+ DX-22: Removed deprecated options (use_code_lines, exclude_doctest_lines).
236
+ These are now always enabled by default.
237
+
191
238
  Examples:
192
239
  >>> cfg = parse_guard_config({"max_file_lines": 400})
193
240
  >>> cfg.max_file_lines
@@ -200,9 +247,6 @@ def parse_guard_config(guard_config: dict[str, Any]) -> RuleConfig:
200
247
  1
201
248
  >>> cfg.rule_exclusions[0].pattern
202
249
  '**/gen/**'
203
- >>> cfg = parse_guard_config({"use_code_lines": "invalid"}) # Invalid type ignored
204
- >>> cfg.use_code_lines # Falls back to model default (False)
205
- False
206
250
  """
207
251
  kwargs: dict[str, Any] = {}
208
252
 
@@ -212,7 +256,8 @@ def parse_guard_config(guard_config: dict[str, Any]) -> RuleConfig:
212
256
  kwargs[key] = val
213
257
 
214
258
  # Bool fields
215
- for key in ("require_contracts", "require_doctests", "strict_pure", "use_code_lines", "exclude_doctest_lines"):
259
+ # DX-22: Removed use_code_lines, exclude_doctest_lines (deprecated)
260
+ for key in ("require_contracts", "require_doctests", "strict_pure"):
216
261
  if (val := _get_bool(guard_config, key)) is not None:
217
262
  kwargs[key] = val
218
263
 
@@ -239,7 +284,7 @@ def parse_guard_config(guard_config: dict[str, Any]) -> RuleConfig:
239
284
  return RuleConfig()
240
285
 
241
286
 
242
- @pre(lambda file_path, patterns: isinstance(file_path, str) and isinstance(patterns, list))
287
+ @pre(lambda file_path, patterns: len(file_path) > 0)
243
288
  def matches_pattern(file_path: str, patterns: list[str]) -> bool:
244
289
  """
245
290
  Check if a file path matches any of the glob patterns.
@@ -269,7 +314,7 @@ def matches_pattern(file_path: str, patterns: list[str]) -> bool:
269
314
  return False
270
315
 
271
316
 
272
- @pre(lambda file_path, prefixes: isinstance(file_path, str) and isinstance(prefixes, list))
317
+ @pre(lambda file_path, prefixes: len(file_path) > 0)
273
318
  def matches_path_prefix(file_path: str, prefixes: list[str]) -> bool:
274
319
  """
275
320
  Check if file_path starts with any of the given prefixes.
@@ -339,7 +384,7 @@ def match_glob_pattern(file_path: str, pattern: str) -> bool:
339
384
  return False
340
385
 
341
386
 
342
- @pre(lambda file_path, config: isinstance(config, RuleConfig))
387
+ @pre(lambda file_path, config: len(file_path) > 0)
343
388
  def get_excluded_rules(file_path: str, config: RuleConfig) -> set[str]:
344
389
  """
345
390
  Get the set of rules to exclude for a given file path.