invar-tools 1.2.0__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +10 -10
  3. invar/core/entry_points.py +105 -32
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +1 -2
  6. invar/core/formatter.py +6 -7
  7. invar/core/hypothesis_strategies.py +5 -7
  8. invar/core/inspect.py +1 -1
  9. invar/core/lambda_helpers.py +3 -3
  10. invar/core/models.py +7 -1
  11. invar/core/must_use.py +2 -1
  12. invar/core/parser.py +7 -4
  13. invar/core/postcondition_scope.py +128 -0
  14. invar/core/property_gen.py +8 -5
  15. invar/core/purity.py +3 -3
  16. invar/core/purity_heuristics.py +5 -9
  17. invar/core/references.py +8 -6
  18. invar/core/review_trigger.py +78 -6
  19. invar/core/rule_meta.py +8 -0
  20. invar/core/rules.py +18 -19
  21. invar/core/shell_analysis.py +5 -10
  22. invar/core/shell_architecture.py +2 -2
  23. invar/core/strategies.py +7 -14
  24. invar/core/suggestions.py +86 -0
  25. invar/core/sync_helpers.py +238 -0
  26. invar/core/tautology.py +102 -37
  27. invar/core/template_parser.py +467 -0
  28. invar/core/timeout_inference.py +4 -7
  29. invar/core/utils.py +13 -15
  30. invar/core/verification_routing.py +4 -7
  31. invar/mcp/server.py +100 -17
  32. invar/shell/commands/__init__.py +11 -0
  33. invar/shell/{cli.py → commands/guard.py} +94 -14
  34. invar/shell/{init_cmd.py → commands/init.py} +179 -27
  35. invar/shell/commands/merge.py +256 -0
  36. invar/shell/commands/sync_self.py +113 -0
  37. invar/shell/commands/template_sync.py +366 -0
  38. invar/shell/commands/update.py +48 -0
  39. invar/shell/config.py +12 -24
  40. invar/shell/coverage.py +351 -0
  41. invar/shell/guard_helpers.py +38 -17
  42. invar/shell/guard_output.py +7 -1
  43. invar/shell/property_tests.py +58 -22
  44. invar/shell/prove/__init__.py +9 -0
  45. invar/shell/{prove.py → prove/crosshair.py} +40 -33
  46. invar/shell/{prove_fallback.py → prove/hypothesis.py} +12 -4
  47. invar/shell/subprocess_env.py +393 -0
  48. invar/shell/template_engine.py +345 -0
  49. invar/shell/templates.py +19 -0
  50. invar/shell/testing.py +71 -20
  51. invar/templates/CLAUDE.md.template +38 -17
  52. invar/templates/aider.conf.yml.template +2 -2
  53. invar/templates/commands/{review.md → audit.md} +20 -82
  54. invar/templates/commands/guard.md +77 -0
  55. invar/templates/config/CLAUDE.md.jinja +206 -0
  56. invar/templates/config/context.md.jinja +92 -0
  57. invar/templates/config/pre-commit.yaml.jinja +44 -0
  58. invar/templates/context.md.template +33 -0
  59. invar/templates/cursorrules.template +7 -4
  60. invar/templates/examples/README.md +2 -0
  61. invar/templates/examples/conftest.py +3 -0
  62. invar/templates/examples/contracts.py +5 -5
  63. invar/templates/examples/core_shell.py +11 -7
  64. invar/templates/examples/workflow.md +81 -0
  65. invar/templates/manifest.toml +137 -0
  66. invar/templates/{INVAR.md → protocol/INVAR.md} +10 -7
  67. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  68. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  69. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  70. invar/templates/skills/review/SKILL.md.jinja +125 -0
  71. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/METADATA +108 -118
  72. invar_tools-1.3.0.dist-info/RECORD +95 -0
  73. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  74. invar/contracts.py +0 -152
  75. invar/decorators.py +0 -94
  76. invar/invariant.py +0 -58
  77. invar/resource.py +0 -99
  78. invar/shell/update_cmd.py +0 -193
  79. invar_tools-1.2.0.dist-info/RECORD +0 -77
  80. invar_tools-1.2.0.dist-info/entry_points.txt +0 -2
  81. /invar/shell/{mutate_cmd.py → commands/mutate.py} +0 -0
  82. /invar/shell/{perception.py → commands/perception.py} +0 -0
  83. /invar/shell/{test_cmd.py → commands/test.py} +0 -0
  84. /invar/shell/{prove_accept.py → prove/accept.py} +0 -0
  85. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  86. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
  87. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE +0 -0
  88. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/LICENSE-GPL +0 -0
  89. {invar_tools-1.2.0.dist-info → invar_tools-1.3.0.dist-info}/licenses/NOTICE +0 -0
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(result, dict))
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: isinstance(s, str) and _NUMBER_PATTERN.match(s.strip()))
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
- lambda pre_source, param_name, param_type=None: isinstance(pre_source, str)
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
- lambda pre_sources, param_name, param_type=None: isinstance(pre_sources, list)
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
- @pre(lambda hint: isinstance(hint, StrategyHint))
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:
@@ -290,6 +360,7 @@ def format_suggestion_for_violation(symbol: Symbol, violation_type: str) -> str:
290
360
  Phase 9.2 P4: Generate lambda skeletons when no type-based suggestion available.
291
361
  P7: Added semantic_tautology support.
292
362
  P27: Show pattern alternatives (Guard provides options, Agent decides).
363
+ DX-XX: Return-type-aware @post suggestions for redundant_type_contract.
293
364
 
294
365
  Examples:
295
366
  >>> from invar.core.models import Symbol, SymbolKind
@@ -306,6 +377,12 @@ def format_suggestion_for_violation(symbol: Symbol, violation_type: str) -> str:
306
377
  >>> msg2 = format_suggestion_for_violation(sym2, "missing_contract")
307
378
  >>> "@pre(lambda data, config: <condition>)" in msg2
308
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
309
386
  """
310
387
  if symbol.kind not in (SymbolKind.FUNCTION, SymbolKind.METHOD):
311
388
  return ""
@@ -322,6 +399,15 @@ def format_suggestion_for_violation(symbol: Symbol, violation_type: str) -> str:
322
399
  patterns = generate_pattern_options(sig)
323
400
  suggestion = generate_contract_suggestion(sig)
324
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
+
325
411
  if suggestion:
326
412
  return _format_with_patterns(suggestion_prefix, suggestion, patterns)
327
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,64 +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
72
  @pre(lambda node: isinstance(node, ast.expr))
52
- @post(lambda result: isinstance(result, tuple) and len(result) == 2)
53
- def _check_tautology_patterns(node: ast.expr) -> tuple[bool, str]:
54
- """Check for common tautology patterns in AST node."""
55
- # Identity comparison pattern (e.g., x == x)
56
- if (
57
- isinstance(node, ast.Compare)
58
- and len(node.ops) == 1
59
- and isinstance(node.ops[0], (ast.Eq, ast.Is))
60
- ):
61
- left = ast.unparse(node.left)
62
- right = ast.unparse(node.comparators[0])
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])
63
90
  if left == right:
64
91
  return (True, f"{left} == {right} is always True")
65
-
66
- # Length non-negative pattern (e.g., len(x) >= 0)
67
- if isinstance(node, ast.Compare) and len(node.ops) == 1 and len(node.comparators) == 1:
68
- left = node.left
69
- op = node.ops[0]
70
- right = node.comparators[0]
71
- if (
72
- isinstance(left, ast.Call)
73
- and isinstance(left.func, ast.Name)
74
- and left.func.id == "len"
75
- and isinstance(op, ast.GtE)
76
- and isinstance(right, ast.Constant)
77
- and right.value == 0
78
- ):
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):
79
98
  arg = ast.unparse(left.args[0]) if left.args else "x"
80
99
  return (True, f"len({arg}) >= 0 is always True for any sequence")
100
+ return None
81
101
 
82
- # isinstance with object pattern (always True)
83
- if (
84
- isinstance(node, ast.Call)
85
- and isinstance(node.func, ast.Name)
86
- and node.func.id == "isinstance"
87
- and len(node.args) == 2
88
- ):
102
+
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):
89
108
  type_arg = node.args[1]
90
109
  if isinstance(type_arg, ast.Name) and type_arg.id == "object":
91
- arg = ast.unparse(node.args[0])
92
- return (True, f"isinstance({arg}, object) is always True")
110
+ return (True, f"isinstance({ast.unparse(node.args[0])}, object) is always True")
111
+ return None
112
+
93
113
 
94
- # Pattern: x or True, True or x (always true)
95
- if isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or):
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
96
121
  for val in node.values:
97
122
  if isinstance(val, ast.Constant) and val.value is True:
98
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
99
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.
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
100
165
  return (False, "")
101
166
 
102
167
 
103
- @pre(lambda file_info, config: isinstance(file_info, FileInfo))
168
+ @pre(lambda file_info, config: len(file_info.path) > 0)
104
169
  def check_semantic_tautology(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
105
170
  """Check for semantic tautology contracts. Core files only.
106
171