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.
- invar/__init__.py +1 -0
- invar/core/contracts.py +10 -10
- invar/core/entry_points.py +105 -32
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +1 -2
- invar/core/formatter.py +6 -7
- invar/core/hypothesis_strategies.py +5 -7
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -3
- invar/core/models.py +7 -1
- invar/core/must_use.py +2 -1
- invar/core/parser.py +7 -4
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +8 -5
- invar/core/purity.py +3 -3
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +78 -6
- invar/core/rule_meta.py +8 -0
- invar/core/rules.py +18 -19
- invar/core/shell_analysis.py +5 -10
- invar/core/shell_architecture.py +2 -2
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +86 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +102 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +13 -15
- invar/core/verification_routing.py +4 -7
- invar/mcp/server.py +100 -17
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +94 -14
- invar/shell/{init_cmd.py → commands/init.py} +179 -27
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +12 -24
- invar/shell/coverage.py +351 -0
- invar/shell/guard_helpers.py +38 -17
- invar/shell/guard_output.py +7 -1
- invar/shell/property_tests.py +58 -22
- invar/shell/prove/__init__.py +9 -0
- invar/shell/{prove.py → prove/crosshair.py} +40 -33
- invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +19 -0
- invar/shell/testing.py +71 -20
- invar/templates/CLAUDE.md.template +38 -17
- invar/templates/aider.conf.yml.template +2 -2
- invar/templates/commands/{review.md → audit.md} +20 -82
- 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 +7 -4
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +5 -5
- invar/templates/examples/core_shell.py +11 -7
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
- 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.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -58
- invar/resource.py +0 -99
- invar/shell/update_cmd.py +0 -193
- invar_tools-1.2.0.dist-info/RECORD +0 -77
- invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
- /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
- /invar/shell/{perception.py → commands/perception.py} +0 -0
- /invar/shell/{test_cmd.py → commands/test.py} +0 -0
- /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {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
|
+
<!-- ======================================== -->"""
|
invar/core/timeout_inference.py
CHANGED
|
@@ -42,7 +42,7 @@ LIBRARY_BLACKLIST = frozenset([
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
@pre(lambda func: callable(func))
|
|
45
|
-
@post(lambda result:
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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': '
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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.
|