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
invar/core/strategies.py
CHANGED
|
@@ -26,7 +26,7 @@ class StrategyHint:
|
|
|
26
26
|
constraints: dict[str, Any] = field(default_factory=dict)
|
|
27
27
|
description: str = ""
|
|
28
28
|
|
|
29
|
-
@post(lambda result: isinstance(
|
|
29
|
+
@post(lambda result: all(isinstance(k, str) for k in result)) # Keys are strings
|
|
30
30
|
def to_hypothesis_args(self) -> dict[str, Any]:
|
|
31
31
|
"""Convert constraints to Hypothesis strategy arguments.
|
|
32
32
|
|
|
@@ -44,8 +44,7 @@ class StrategyHint:
|
|
|
44
44
|
_NUMBER_PATTERN = re.compile(r"^-?[0-9]+\.?[0-9]*(?:e[+-]?[0-9]+)?$", re.IGNORECASE)
|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
@pre(lambda s:
|
|
48
|
-
@post(lambda result: isinstance(result, (int, float)))
|
|
47
|
+
@pre(lambda s: _NUMBER_PATTERN.match(s.strip())) # Valid number format
|
|
49
48
|
def _parse_number(s: str) -> int | float:
|
|
50
49
|
"""Parse a number string to int or float.
|
|
51
50
|
|
|
@@ -141,11 +140,8 @@ PATTERNS: list[tuple[str, Callable[[re.Match, str], dict[str, Any] | None]]] = [
|
|
|
141
140
|
]
|
|
142
141
|
|
|
143
142
|
|
|
144
|
-
@pre(
|
|
145
|
-
|
|
146
|
-
and isinstance(param_name, str)
|
|
147
|
-
)
|
|
148
|
-
@post(lambda result: isinstance(result, StrategyHint))
|
|
143
|
+
@pre(lambda pre_source, param_name, param_type=None: len(param_name) > 0) # Param must be named
|
|
144
|
+
@post(lambda result: isinstance(result.constraints, dict)) # Returns valid hint
|
|
149
145
|
def infer_from_lambda(
|
|
150
146
|
pre_source: str,
|
|
151
147
|
param_name: str,
|
|
@@ -197,10 +193,8 @@ def infer_from_lambda(
|
|
|
197
193
|
)
|
|
198
194
|
|
|
199
195
|
|
|
200
|
-
@pre(
|
|
201
|
-
|
|
202
|
-
and isinstance(param_name, str)
|
|
203
|
-
)
|
|
196
|
+
@pre(lambda pre_sources, param_name, param_type=None: len(param_name) > 0) # Param must be named
|
|
197
|
+
@post(lambda result: isinstance(result.constraints, dict)) # Returns valid hint
|
|
204
198
|
def infer_from_multiple(
|
|
205
199
|
pre_sources: list[str],
|
|
206
200
|
param_name: str,
|
|
@@ -232,8 +226,7 @@ def infer_from_multiple(
|
|
|
232
226
|
)
|
|
233
227
|
|
|
234
228
|
|
|
235
|
-
@
|
|
236
|
-
@post(lambda result: isinstance(result, str))
|
|
229
|
+
@post(lambda result: ":" in result) # Format is "name: strategy"
|
|
237
230
|
def format_strategy_hint(hint: StrategyHint) -> str:
|
|
238
231
|
"""
|
|
239
232
|
Format a strategy hint as a human-readable string.
|
invar/core/suggestions.py
CHANGED
|
@@ -32,6 +32,76 @@ CONSTRAINT_PATTERNS: dict[str, list[str]] = {
|
|
|
32
32
|
"Optional": ["{name} is not None", "{name}"],
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
# Return-type-aware @post patterns for redundant_type_contract suggestions
|
|
36
|
+
RETURN_TYPE_POST_PATTERNS: dict[str, str] = {
|
|
37
|
+
"list[Violation]": '@post(lambda result: all(v.rule == "RULE_NAME" for v in result))',
|
|
38
|
+
"list": '@post(lambda result: all(<predicate> for item in result))',
|
|
39
|
+
"dict": "@post(lambda result: all(isinstance(k, <type>) for k in result))",
|
|
40
|
+
"set": "@post(lambda result: all(<predicate> for item in result))",
|
|
41
|
+
"int": "@post(lambda result: result >= 0)",
|
|
42
|
+
"float": "@post(lambda result: result >= 0.0)",
|
|
43
|
+
"str": "@post(lambda result: len(result) > 0)",
|
|
44
|
+
"bool": "@post(lambda result: <semantic_predicate>)",
|
|
45
|
+
"None": "", # No meaningful @post for None return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@post(lambda result: result is None or isinstance(result, str))
|
|
50
|
+
def extract_return_type(signature: str) -> str | None:
|
|
51
|
+
"""Extract return type from function signature.
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
>>> extract_return_type("(x: int) -> list[Violation]")
|
|
55
|
+
'list[Violation]'
|
|
56
|
+
>>> extract_return_type("(x: int) -> int")
|
|
57
|
+
'int'
|
|
58
|
+
>>> extract_return_type("(x: int) -> None")
|
|
59
|
+
'None'
|
|
60
|
+
>>> extract_return_type("(x: int)")
|
|
61
|
+
>>> extract_return_type("()")
|
|
62
|
+
"""
|
|
63
|
+
if not signature or "->" not in signature:
|
|
64
|
+
return None
|
|
65
|
+
match = re.search(r"->\s*(.+)$", signature)
|
|
66
|
+
if match:
|
|
67
|
+
return match.group(1).strip()
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pre(lambda return_type: return_type is None or isinstance(return_type, str))
|
|
72
|
+
@post(lambda result: isinstance(result, str))
|
|
73
|
+
def generate_post_suggestion(return_type: str | None) -> str:
|
|
74
|
+
"""Generate @post suggestion based on return type.
|
|
75
|
+
|
|
76
|
+
Examples:
|
|
77
|
+
>>> generate_post_suggestion("list[Violation]")
|
|
78
|
+
'@post(lambda result: all(v.rule == "RULE_NAME" for v in result))'
|
|
79
|
+
>>> generate_post_suggestion("int")
|
|
80
|
+
'@post(lambda result: result >= 0)'
|
|
81
|
+
>>> generate_post_suggestion("bool")
|
|
82
|
+
'@post(lambda result: <semantic_predicate>)'
|
|
83
|
+
>>> generate_post_suggestion("CustomType")
|
|
84
|
+
'@post(lambda result: <condition>)'
|
|
85
|
+
>>> generate_post_suggestion(None)
|
|
86
|
+
'@post(lambda result: <condition>)'
|
|
87
|
+
"""
|
|
88
|
+
if not return_type:
|
|
89
|
+
return "@post(lambda result: <condition>)"
|
|
90
|
+
|
|
91
|
+
# Exact match
|
|
92
|
+
if return_type in RETURN_TYPE_POST_PATTERNS:
|
|
93
|
+
pattern = RETURN_TYPE_POST_PATTERNS[return_type]
|
|
94
|
+
return pattern if pattern else "@post(lambda result: <condition>)"
|
|
95
|
+
|
|
96
|
+
# Generic match (list[X], dict[K,V], etc.)
|
|
97
|
+
base_match = re.match(r"^(list|dict|set)\[", return_type)
|
|
98
|
+
if base_match:
|
|
99
|
+
base = base_match.group(1)
|
|
100
|
+
if base in RETURN_TYPE_POST_PATTERNS:
|
|
101
|
+
return RETURN_TYPE_POST_PATTERNS[base]
|
|
102
|
+
|
|
103
|
+
return "@post(lambda result: <condition>)"
|
|
104
|
+
|
|
35
105
|
|
|
36
106
|
@pre(lambda signature: signature.startswith("(") or signature == "")
|
|
37
107
|
def generate_contract_suggestion(signature: str) -> str:
|
|
@@ -65,6 +135,8 @@ def generate_contract_suggestion(signature: str) -> str:
|
|
|
65
135
|
for name, type_hint in params:
|
|
66
136
|
if not name: # Skip empty names from malformed signatures
|
|
67
137
|
continue
|
|
138
|
+
if name in ("self", "cls"): # Skip method receiver parameters
|
|
139
|
+
continue
|
|
68
140
|
param_names.append(name)
|
|
69
141
|
if not type_hint:
|
|
70
142
|
continue
|
|
@@ -86,6 +158,10 @@ def _extract_params(signature: str) -> list[tuple[str, str | None]]:
|
|
|
86
158
|
"""
|
|
87
159
|
Extract parameters and their types from a signature.
|
|
88
160
|
|
|
161
|
+
MINOR-2 Limitation: Uses naive comma splitting which breaks for complex types
|
|
162
|
+
like Callable[[int, str], bool] where commas appear inside nested brackets.
|
|
163
|
+
This is acceptable since suggestions are advisory, not strict validation.
|
|
164
|
+
|
|
89
165
|
Examples:
|
|
90
166
|
>>> _extract_params("(x: int, y: str) -> bool")
|
|
91
167
|
[('x', 'int'), ('y', 'str')]
|
|
@@ -284,6 +360,7 @@ def format_suggestion_for_violation(symbol: Symbol, violation_type: str) -> str:
|
|
|
284
360
|
Phase 9.2 P4: Generate lambda skeletons when no type-based suggestion available.
|
|
285
361
|
P7: Added semantic_tautology support.
|
|
286
362
|
P27: Show pattern alternatives (Guard provides options, Agent decides).
|
|
363
|
+
DX-XX: Return-type-aware @post suggestions for redundant_type_contract.
|
|
287
364
|
|
|
288
365
|
Examples:
|
|
289
366
|
>>> from invar.core.models import Symbol, SymbolKind
|
|
@@ -300,6 +377,12 @@ def format_suggestion_for_violation(symbol: Symbol, violation_type: str) -> str:
|
|
|
300
377
|
>>> msg2 = format_suggestion_for_violation(sym2, "missing_contract")
|
|
301
378
|
>>> "@pre(lambda data, config: <condition>)" in msg2
|
|
302
379
|
True
|
|
380
|
+
>>> # Return-type-aware @post for redundant_type_contract
|
|
381
|
+
>>> sym3 = Symbol(name="check", kind=SymbolKind.FUNCTION, line=1, end_line=5,
|
|
382
|
+
... signature="(x: int) -> list[Violation]")
|
|
383
|
+
>>> msg3 = format_suggestion_for_violation(sym3, "redundant_type_contract")
|
|
384
|
+
>>> 'all(v.rule ==' in msg3
|
|
385
|
+
True
|
|
303
386
|
"""
|
|
304
387
|
if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
305
388
|
return ""
|
|
@@ -316,6 +399,15 @@ def format_suggestion_for_violation(symbol: Symbol, violation_type: str) -> str:
|
|
|
316
399
|
patterns = generate_pattern_options(sig)
|
|
317
400
|
suggestion = generate_contract_suggestion(sig)
|
|
318
401
|
|
|
402
|
+
# For redundant_type_contract, include return-type-aware @post suggestion
|
|
403
|
+
if violation_type == "redundant_type_contract":
|
|
404
|
+
return_type = extract_return_type(sig)
|
|
405
|
+
post_suggestion = generate_post_suggestion(return_type)
|
|
406
|
+
if suggestion:
|
|
407
|
+
full_suggestion = f"{suggestion}\n or {post_suggestion}"
|
|
408
|
+
return _format_with_patterns(suggestion_prefix, full_suggestion, patterns)
|
|
409
|
+
return f"{skeleton_prefix}{post_suggestion}"
|
|
410
|
+
|
|
319
411
|
if suggestion:
|
|
320
412
|
return _format_with_patterns(suggestion_prefix, suggestion, patterns)
|
|
321
413
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DX-56: Pure sync helper functions.
|
|
3
|
+
|
|
4
|
+
Core module: Pure logic for template sync operations.
|
|
5
|
+
No I/O - only data transformation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from deal import post, pre
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from invar.core.template_parser import ParsedFile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# =============================================================================
|
|
20
|
+
# Data Types
|
|
21
|
+
# =============================================================================
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SyncConfig:
|
|
26
|
+
"""Configuration for template sync operation.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> config = SyncConfig()
|
|
30
|
+
>>> config.syntax
|
|
31
|
+
'cli'
|
|
32
|
+
>>> config.inject_project_additions
|
|
33
|
+
False
|
|
34
|
+
|
|
35
|
+
>>> config = SyncConfig(syntax="mcp", force=True)
|
|
36
|
+
>>> config.syntax
|
|
37
|
+
'mcp'
|
|
38
|
+
>>> config.force
|
|
39
|
+
True
|
|
40
|
+
|
|
41
|
+
>>> config = SyncConfig(skip_patterns=[".claude/skills/*"])
|
|
42
|
+
>>> config.skip_patterns
|
|
43
|
+
['.claude/skills/*']
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
syntax: str = "cli" # "cli" or "mcp"
|
|
47
|
+
inject_project_additions: bool = False
|
|
48
|
+
force: bool = False
|
|
49
|
+
check: bool = False # Preview only
|
|
50
|
+
reset: bool = False # Discard user content
|
|
51
|
+
skip_patterns: list[str] = field(default_factory=list) # Glob patterns to skip
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SyncReport:
|
|
56
|
+
"""Result of sync operation.
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
>>> report = SyncReport()
|
|
60
|
+
>>> report.created
|
|
61
|
+
[]
|
|
62
|
+
>>> len(report.updated)
|
|
63
|
+
0
|
|
64
|
+
|
|
65
|
+
>>> report = SyncReport(created=["INVAR.md"], updated=["CLAUDE.md"])
|
|
66
|
+
>>> report.created
|
|
67
|
+
['INVAR.md']
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
created: list[str] = field(default_factory=list)
|
|
71
|
+
updated: list[str] = field(default_factory=list)
|
|
72
|
+
skipped: list[str] = field(default_factory=list)
|
|
73
|
+
errors: list[str] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# =============================================================================
|
|
77
|
+
# Manifest Helpers
|
|
78
|
+
# =============================================================================
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pre(lambda path, patterns: len(path) > 0 and isinstance(patterns, list))
|
|
82
|
+
@post(lambda result: isinstance(result, bool))
|
|
83
|
+
def should_skip_file(path: str, patterns: list[str]) -> bool:
|
|
84
|
+
"""Check if path should be skipped based on skip patterns.
|
|
85
|
+
|
|
86
|
+
Examples:
|
|
87
|
+
>>> should_skip_file(".claude/skills/develop/SKILL.md", [".claude/skills/*"])
|
|
88
|
+
True
|
|
89
|
+
>>> should_skip_file("CLAUDE.md", [".claude/skills/*"])
|
|
90
|
+
False
|
|
91
|
+
>>> should_skip_file("INVAR.md", [])
|
|
92
|
+
False
|
|
93
|
+
"""
|
|
94
|
+
return any(matches_glob_pattern(path, pattern) for pattern in patterns)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pre(lambda path, pattern: len(path) > 0 and len(pattern) > 0)
|
|
98
|
+
@post(lambda result: isinstance(result, bool))
|
|
99
|
+
def matches_glob_pattern(path: str, pattern: str) -> bool:
|
|
100
|
+
"""Check if path matches a simple glob pattern with *.
|
|
101
|
+
|
|
102
|
+
Examples:
|
|
103
|
+
>>> matches_glob_pattern(".claude/skills/develop/SKILL.md", ".claude/skills/*/SKILL.md")
|
|
104
|
+
True
|
|
105
|
+
>>> matches_glob_pattern(".claude/skills/review/SKILL.md", ".claude/skills/*/SKILL.md")
|
|
106
|
+
True
|
|
107
|
+
>>> matches_glob_pattern("CLAUDE.md", ".claude/skills/*/SKILL.md")
|
|
108
|
+
False
|
|
109
|
+
>>> matches_glob_pattern("INVAR.md", "INVAR.md")
|
|
110
|
+
True
|
|
111
|
+
"""
|
|
112
|
+
if "*" not in pattern:
|
|
113
|
+
return path == pattern
|
|
114
|
+
|
|
115
|
+
parts = pattern.split("*")
|
|
116
|
+
if len(parts) != 2:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
prefix, suffix = parts
|
|
120
|
+
return path.startswith(prefix) and path.endswith(suffix)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pre(lambda manifest: "templates" in manifest or "sync" in manifest)
|
|
124
|
+
@post(lambda result: isinstance(result, tuple) and len(result) == 3)
|
|
125
|
+
def get_sync_file_lists(
|
|
126
|
+
manifest: dict,
|
|
127
|
+
) -> tuple[list[tuple[str, str]], list[tuple[str, str]], list[str]]:
|
|
128
|
+
"""Extract file lists from manifest for sync operations.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Tuple of (fully_managed, region_managed, create_only) file lists.
|
|
132
|
+
- fully_managed: List of (dest, src) tuples for full overwrite
|
|
133
|
+
- region_managed: List of (dest, src) tuples for region-based updates
|
|
134
|
+
- create_only: List of destination paths created once
|
|
135
|
+
|
|
136
|
+
Examples:
|
|
137
|
+
>>> manifest = {
|
|
138
|
+
... "sync": {
|
|
139
|
+
... "fully_managed": ["INVAR.md"],
|
|
140
|
+
... "region_managed": ["CLAUDE.md"],
|
|
141
|
+
... "create_only": [".invar/context.md"],
|
|
142
|
+
... },
|
|
143
|
+
... "templates": {
|
|
144
|
+
... "INVAR.md": {"src": "protocol/INVAR.md", "type": "copy"},
|
|
145
|
+
... "CLAUDE.md": {"src": "config/CLAUDE.md.jinja", "type": "jinja"},
|
|
146
|
+
... }
|
|
147
|
+
... }
|
|
148
|
+
>>> fm, rm, co = get_sync_file_lists(manifest)
|
|
149
|
+
>>> len(fm)
|
|
150
|
+
1
|
|
151
|
+
>>> fm[0][0]
|
|
152
|
+
'INVAR.md'
|
|
153
|
+
"""
|
|
154
|
+
sync_config = manifest.get("sync", {})
|
|
155
|
+
templates = manifest.get("templates", {})
|
|
156
|
+
|
|
157
|
+
fully_managed = []
|
|
158
|
+
for dest in sync_config.get("fully_managed", []):
|
|
159
|
+
if dest in templates:
|
|
160
|
+
fully_managed.append((dest, templates[dest].get("src", "")))
|
|
161
|
+
|
|
162
|
+
region_managed = []
|
|
163
|
+
for dest in sync_config.get("region_managed", []):
|
|
164
|
+
if dest in templates:
|
|
165
|
+
region_managed.append((dest, templates[dest].get("src", "")))
|
|
166
|
+
elif "*" in dest:
|
|
167
|
+
# Handle glob patterns like ".claude/skills/*/SKILL.md"
|
|
168
|
+
for template_dest, template_config in templates.items():
|
|
169
|
+
if matches_glob_pattern(template_dest, dest):
|
|
170
|
+
region_managed.append((template_dest, template_config.get("src", "")))
|
|
171
|
+
|
|
172
|
+
create_only = sync_config.get("create_only", [])
|
|
173
|
+
|
|
174
|
+
return fully_managed, region_managed, create_only
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@pre(lambda manifest, file_path: "regions" in manifest and len(file_path) > 0)
|
|
178
|
+
def get_region_config(manifest: dict, file_path: str) -> dict[str, dict] | None:
|
|
179
|
+
"""Get region configuration for a file from manifest.
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
>>> manifest = {
|
|
183
|
+
... "regions": {
|
|
184
|
+
... "CLAUDE.md": {
|
|
185
|
+
... "managed": {"action": "overwrite"},
|
|
186
|
+
... "user": {"action": "preserve"},
|
|
187
|
+
... }
|
|
188
|
+
... }
|
|
189
|
+
... }
|
|
190
|
+
>>> config = get_region_config(manifest, "CLAUDE.md")
|
|
191
|
+
>>> config["managed"]["action"]
|
|
192
|
+
'overwrite'
|
|
193
|
+
>>> get_region_config(manifest, "unknown.md") is None
|
|
194
|
+
True
|
|
195
|
+
"""
|
|
196
|
+
regions = manifest.get("regions", {})
|
|
197
|
+
|
|
198
|
+
# Direct match
|
|
199
|
+
if file_path in regions:
|
|
200
|
+
return regions[file_path]
|
|
201
|
+
|
|
202
|
+
# Glob pattern match
|
|
203
|
+
for pattern, config in regions.items():
|
|
204
|
+
if matches_glob_pattern(file_path, pattern):
|
|
205
|
+
return config
|
|
206
|
+
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@pre(lambda parsed: hasattr(parsed, "regions"))
|
|
211
|
+
def detect_region_scheme(parsed: ParsedFile) -> tuple[str, str] | None:
|
|
212
|
+
"""Detect the region scheme from parsed file.
|
|
213
|
+
|
|
214
|
+
Returns (primary_region, user_region) or None if no known scheme.
|
|
215
|
+
|
|
216
|
+
Examples:
|
|
217
|
+
>>> from invar.core.template_parser import ParsedFile, Region
|
|
218
|
+
>>> p = ParsedFile(regions={"managed": Region("managed", 0, 10, "")})
|
|
219
|
+
>>> detect_region_scheme(p)
|
|
220
|
+
('managed', 'user')
|
|
221
|
+
>>> p2 = ParsedFile(regions={"skill": Region("skill", 0, 10, "")})
|
|
222
|
+
>>> detect_region_scheme(p2)
|
|
223
|
+
('skill', 'extensions')
|
|
224
|
+
>>> p3 = ParsedFile(regions={})
|
|
225
|
+
>>> detect_region_scheme(p3) is None
|
|
226
|
+
True
|
|
227
|
+
"""
|
|
228
|
+
# Known schemes
|
|
229
|
+
schemes = {
|
|
230
|
+
"managed": ("managed", "user"),
|
|
231
|
+
"skill": ("skill", "extensions"),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for primary, scheme in schemes.items():
|
|
235
|
+
if primary in parsed.regions:
|
|
236
|
+
return scheme
|
|
237
|
+
|
|
238
|
+
return None
|
invar/core/tautology.py
CHANGED
|
@@ -24,6 +24,11 @@ def is_semantic_tautology(expression: str) -> tuple[bool, str]:
|
|
|
24
24
|
- x or True (always true due to True)
|
|
25
25
|
- True and x (simplifies but starts with True)
|
|
26
26
|
|
|
27
|
+
DX-38 Tier 1: Also detects obvious violations:
|
|
28
|
+
- lambda x: True (no constraint)
|
|
29
|
+
- lambda x: False (contradiction)
|
|
30
|
+
- lambda: ... (no parameters - doesn't validate function inputs)
|
|
31
|
+
|
|
27
32
|
Examples:
|
|
28
33
|
>>> is_semantic_tautology("lambda x: x == x")
|
|
29
34
|
(True, 'x == x is always True')
|
|
@@ -35,6 +40,16 @@ def is_semantic_tautology(expression: str) -> tuple[bool, str]:
|
|
|
35
40
|
(False, '')
|
|
36
41
|
>>> is_semantic_tautology("lambda x: x or True")
|
|
37
42
|
(True, 'expression contains unconditional True')
|
|
43
|
+
>>> is_semantic_tautology("lambda x: True")
|
|
44
|
+
(True, 'contract always returns True (no constraint)')
|
|
45
|
+
>>> is_semantic_tautology("lambda x: False")
|
|
46
|
+
(True, 'contract always returns False (contradiction - will always fail)')
|
|
47
|
+
>>> is_semantic_tautology("lambda: len([1,2]) > 0")
|
|
48
|
+
(True, "contract has no parameters (doesn't validate function inputs)")
|
|
49
|
+
>>> is_semantic_tautology("lambda result: result or not result")
|
|
50
|
+
(True, "'result or not result' is always True (tautology)")
|
|
51
|
+
>>> is_semantic_tautology("lambda x: x and not x")
|
|
52
|
+
(True, "'x and not x' is always False (contradiction)")
|
|
38
53
|
"""
|
|
39
54
|
if not expression.strip():
|
|
40
55
|
return (False, "")
|
|
@@ -43,63 +58,114 @@ def is_semantic_tautology(expression: str) -> tuple[bool, str]:
|
|
|
43
58
|
lambda_node = find_lambda(tree)
|
|
44
59
|
if lambda_node is None:
|
|
45
60
|
return (False, "")
|
|
61
|
+
|
|
62
|
+
# DX-38 Tier 1: Check for no-parameter lambda
|
|
63
|
+
args = lambda_node.args
|
|
64
|
+
if not args.args and not args.posonlyargs and not args.kwonlyargs and not args.vararg and not args.kwarg:
|
|
65
|
+
return (True, "contract has no parameters (doesn't validate function inputs)")
|
|
66
|
+
|
|
46
67
|
return _check_tautology_patterns(lambda_node.body)
|
|
47
68
|
except (SyntaxError, TypeError, ValueError):
|
|
48
69
|
return (False, "")
|
|
49
70
|
|
|
50
71
|
|
|
51
|
-
@
|
|
52
|
-
def
|
|
53
|
-
"""Check for
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
@pre(lambda node: isinstance(node, ast.expr))
|
|
73
|
+
def _check_literal_patterns(node: ast.expr) -> tuple[bool, str] | None:
|
|
74
|
+
"""Check for literal True/False patterns."""
|
|
75
|
+
if isinstance(node, ast.Constant) and node.value is True:
|
|
76
|
+
return (True, "contract always returns True (no constraint)")
|
|
77
|
+
if isinstance(node, ast.Constant) and node.value is False:
|
|
78
|
+
return (True, "contract always returns False (contradiction - will always fail)")
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pre(lambda node: isinstance(node, ast.expr))
|
|
83
|
+
def _check_comparison_patterns(node: ast.expr) -> tuple[bool, str] | None:
|
|
84
|
+
"""Check for identity and len >= 0 patterns."""
|
|
85
|
+
if not isinstance(node, ast.Compare) or len(node.ops) != 1:
|
|
86
|
+
return None
|
|
87
|
+
# Identity comparison pattern
|
|
88
|
+
if isinstance(node.ops[0], (ast.Eq, ast.Is)):
|
|
89
|
+
left, right = ast.unparse(node.left), ast.unparse(node.comparators[0])
|
|
62
90
|
if left == right:
|
|
63
91
|
return (True, f"{left} == {right} is always True")
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
left
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
isinstance(left, ast.Call)
|
|
72
|
-
and isinstance(left.func, ast.Name)
|
|
73
|
-
and left.func.id == "len"
|
|
74
|
-
and isinstance(op, ast.GtE)
|
|
75
|
-
and isinstance(right, ast.Constant)
|
|
76
|
-
and right.value == 0
|
|
77
|
-
):
|
|
92
|
+
# Length non-negative pattern
|
|
93
|
+
if len(node.comparators) == 1:
|
|
94
|
+
left, op, right = node.left, node.ops[0], node.comparators[0]
|
|
95
|
+
if (isinstance(left, ast.Call) and isinstance(left.func, ast.Name) and
|
|
96
|
+
left.func.id == "len" and isinstance(op, ast.GtE) and
|
|
97
|
+
isinstance(right, ast.Constant) and right.value == 0):
|
|
78
98
|
arg = ast.unparse(left.args[0]) if left.args else "x"
|
|
79
99
|
return (True, f"len({arg}) >= 0 is always True for any sequence")
|
|
100
|
+
return None
|
|
101
|
+
|
|
80
102
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
and len(node.args) == 2
|
|
87
|
-
):
|
|
103
|
+
@pre(lambda node: isinstance(node, ast.expr))
|
|
104
|
+
def _check_isinstance_object(node: ast.expr) -> tuple[bool, str] | None:
|
|
105
|
+
"""Check for isinstance(x, object) pattern."""
|
|
106
|
+
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and
|
|
107
|
+
node.func.id == "isinstance" and len(node.args) == 2):
|
|
88
108
|
type_arg = node.args[1]
|
|
89
109
|
if isinstance(type_arg, ast.Name) and type_arg.id == "object":
|
|
90
|
-
|
|
91
|
-
|
|
110
|
+
return (True, f"isinstance({ast.unparse(node.args[0])}, object) is always True")
|
|
111
|
+
return None
|
|
92
112
|
|
|
93
|
-
|
|
94
|
-
|
|
113
|
+
|
|
114
|
+
@pre(lambda node: isinstance(node, ast.expr))
|
|
115
|
+
def _check_boolop_patterns(node: ast.expr) -> tuple[bool, str] | None:
|
|
116
|
+
"""Check for boolean operation patterns: x or True, x or not x, x and not x."""
|
|
117
|
+
if not isinstance(node, ast.BoolOp):
|
|
118
|
+
return None
|
|
119
|
+
if isinstance(node.op, ast.Or):
|
|
120
|
+
# x or True
|
|
95
121
|
for val in node.values:
|
|
96
122
|
if isinstance(val, ast.Constant) and val.value is True:
|
|
97
123
|
return (True, "expression contains unconditional True")
|
|
124
|
+
# Complement tautology pattern
|
|
125
|
+
values_unparsed = {ast.unparse(v): v for v in node.values}
|
|
126
|
+
for val in node.values:
|
|
127
|
+
if isinstance(val, ast.UnaryOp) and isinstance(val.op, ast.Not):
|
|
128
|
+
negated = ast.unparse(val.operand)
|
|
129
|
+
if negated in values_unparsed:
|
|
130
|
+
return (True, f"'{negated} or not {negated}' is always True (tautology)")
|
|
131
|
+
if isinstance(node.op, ast.And):
|
|
132
|
+
# Complement contradiction pattern
|
|
133
|
+
values_unparsed = {ast.unparse(v): v for v in node.values}
|
|
134
|
+
for val in node.values:
|
|
135
|
+
if isinstance(val, ast.UnaryOp) and isinstance(val.op, ast.Not):
|
|
136
|
+
negated = ast.unparse(val.operand)
|
|
137
|
+
if negated in values_unparsed:
|
|
138
|
+
return (True, f"'{negated} and not {negated}' is always False (contradiction)")
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pre(lambda node: isinstance(node, ast.expr) and hasattr(node, '__class__'))
|
|
143
|
+
@post(lambda result: isinstance(result, tuple) and len(result) == 2)
|
|
144
|
+
def _check_tautology_patterns(node: ast.expr) -> tuple[bool, str]:
|
|
145
|
+
"""Check for common tautology patterns in AST node.
|
|
98
146
|
|
|
147
|
+
DX-38 Tier 1: Detects obvious violations:
|
|
148
|
+
- Literal True (always passes, no constraint)
|
|
149
|
+
- Literal False (always fails, contradiction)
|
|
150
|
+
- x == x, len(x) >= 0, isinstance(x, object), x or True
|
|
151
|
+
- x or not x (tautology), x and not x (contradiction)
|
|
152
|
+
|
|
153
|
+
Examples:
|
|
154
|
+
>>> import ast
|
|
155
|
+
>>> _check_tautology_patterns(ast.Constant(value=True))
|
|
156
|
+
(True, 'contract always returns True (no constraint)')
|
|
157
|
+
>>> _check_tautology_patterns(ast.Constant(value=False))
|
|
158
|
+
(True, 'contract always returns False (contradiction - will always fail)')
|
|
159
|
+
"""
|
|
160
|
+
for checker in [_check_literal_patterns, _check_comparison_patterns,
|
|
161
|
+
_check_isinstance_object, _check_boolop_patterns]:
|
|
162
|
+
result = checker(node)
|
|
163
|
+
if result:
|
|
164
|
+
return result
|
|
99
165
|
return (False, "")
|
|
100
166
|
|
|
101
167
|
|
|
102
|
-
@pre(lambda file_info, config:
|
|
168
|
+
@pre(lambda file_info, config: len(file_info.path) > 0)
|
|
103
169
|
def check_semantic_tautology(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
104
170
|
"""Check for semantic tautology contracts. Core files only.
|
|
105
171
|
|