invar-tools 1.0.0__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- invar/__init__.py +1 -0
- invar/core/contracts.py +80 -10
- invar/core/entry_points.py +367 -0
- invar/core/extraction.py +5 -6
- invar/core/format_specs.py +195 -0
- invar/core/format_strategies.py +197 -0
- invar/core/formatter.py +32 -10
- invar/core/hypothesis_strategies.py +50 -10
- invar/core/inspect.py +1 -1
- invar/core/lambda_helpers.py +3 -2
- invar/core/models.py +30 -18
- invar/core/must_use.py +2 -1
- invar/core/parser.py +13 -6
- invar/core/postcondition_scope.py +128 -0
- invar/core/property_gen.py +86 -42
- invar/core/purity.py +13 -7
- invar/core/purity_heuristics.py +5 -9
- invar/core/references.py +8 -6
- invar/core/review_trigger.py +370 -0
- invar/core/rule_meta.py +69 -2
- invar/core/rules.py +91 -28
- invar/core/shell_analysis.py +247 -0
- invar/core/shell_architecture.py +171 -0
- invar/core/strategies.py +7 -14
- invar/core/suggestions.py +92 -0
- invar/core/sync_helpers.py +238 -0
- invar/core/tautology.py +103 -37
- invar/core/template_parser.py +467 -0
- invar/core/timeout_inference.py +4 -7
- invar/core/utils.py +63 -18
- invar/core/verification_routing.py +155 -0
- invar/mcp/server.py +113 -13
- invar/shell/commands/__init__.py +11 -0
- invar/shell/{cli.py → commands/guard.py} +152 -44
- invar/shell/{init_cmd.py → commands/init.py} +200 -28
- invar/shell/commands/merge.py +256 -0
- invar/shell/commands/mutate.py +184 -0
- invar/shell/{perception.py → commands/perception.py} +2 -0
- invar/shell/commands/sync_self.py +113 -0
- invar/shell/commands/template_sync.py +366 -0
- invar/shell/{test_cmd.py → commands/test.py} +3 -1
- invar/shell/commands/update.py +48 -0
- invar/shell/config.py +247 -10
- invar/shell/coverage.py +351 -0
- invar/shell/fs.py +5 -2
- invar/shell/git.py +2 -0
- invar/shell/guard_helpers.py +116 -20
- invar/shell/guard_output.py +106 -24
- invar/shell/mcp_config.py +3 -0
- invar/shell/mutation.py +314 -0
- invar/shell/property_tests.py +75 -24
- invar/shell/prove/__init__.py +9 -0
- invar/shell/prove/accept.py +113 -0
- invar/shell/{prove.py → prove/crosshair.py} +69 -30
- invar/shell/prove/hypothesis.py +293 -0
- invar/shell/subprocess_env.py +393 -0
- invar/shell/template_engine.py +345 -0
- invar/shell/templates.py +53 -0
- invar/shell/testing.py +77 -37
- invar/templates/CLAUDE.md.template +86 -9
- invar/templates/aider.conf.yml.template +16 -14
- invar/templates/commands/audit.md +138 -0
- invar/templates/commands/guard.md +77 -0
- invar/templates/config/CLAUDE.md.jinja +206 -0
- invar/templates/config/context.md.jinja +92 -0
- invar/templates/config/pre-commit.yaml.jinja +44 -0
- invar/templates/context.md.template +33 -0
- invar/templates/cursorrules.template +25 -13
- invar/templates/examples/README.md +2 -0
- invar/templates/examples/conftest.py +3 -0
- invar/templates/examples/contracts.py +4 -2
- invar/templates/examples/core_shell.py +10 -4
- invar/templates/examples/workflow.md +81 -0
- invar/templates/manifest.toml +137 -0
- invar/templates/protocol/INVAR.md +210 -0
- invar/templates/skills/develop/SKILL.md.jinja +318 -0
- invar/templates/skills/investigate/SKILL.md.jinja +106 -0
- invar/templates/skills/propose/SKILL.md.jinja +104 -0
- invar/templates/skills/review/SKILL.md.jinja +125 -0
- invar_tools-1.3.0.dist-info/METADATA +377 -0
- invar_tools-1.3.0.dist-info/RECORD +95 -0
- invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
- invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
- invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
- invar/contracts.py +0 -152
- invar/decorators.py +0 -94
- invar/invariant.py +0 -57
- invar/resource.py +0 -99
- invar/shell/prove_fallback.py +0 -183
- invar/shell/update_cmd.py +0 -191
- invar/templates/INVAR.md +0 -134
- invar_tools-1.0.0.dist-info/METADATA +0 -321
- invar_tools-1.0.0.dist-info/RECORD +0 -64
- invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
- invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
- /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
- {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,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,53 @@ def get_exit_code(report: GuardReport, strict: bool) -> int:
|
|
|
37
37
|
return 0
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
@pre(lambda
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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': '
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|