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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. invar/__init__.py +1 -0
  2. invar/core/contracts.py +80 -10
  3. invar/core/entry_points.py +367 -0
  4. invar/core/extraction.py +5 -6
  5. invar/core/format_specs.py +195 -0
  6. invar/core/format_strategies.py +197 -0
  7. invar/core/formatter.py +32 -10
  8. invar/core/hypothesis_strategies.py +50 -10
  9. invar/core/inspect.py +1 -1
  10. invar/core/lambda_helpers.py +3 -2
  11. invar/core/models.py +30 -18
  12. invar/core/must_use.py +2 -1
  13. invar/core/parser.py +13 -6
  14. invar/core/postcondition_scope.py +128 -0
  15. invar/core/property_gen.py +86 -42
  16. invar/core/purity.py +13 -7
  17. invar/core/purity_heuristics.py +5 -9
  18. invar/core/references.py +8 -6
  19. invar/core/review_trigger.py +370 -0
  20. invar/core/rule_meta.py +69 -2
  21. invar/core/rules.py +91 -28
  22. invar/core/shell_analysis.py +247 -0
  23. invar/core/shell_architecture.py +171 -0
  24. invar/core/strategies.py +7 -14
  25. invar/core/suggestions.py +92 -0
  26. invar/core/sync_helpers.py +238 -0
  27. invar/core/tautology.py +103 -37
  28. invar/core/template_parser.py +467 -0
  29. invar/core/timeout_inference.py +4 -7
  30. invar/core/utils.py +63 -18
  31. invar/core/verification_routing.py +155 -0
  32. invar/mcp/server.py +113 -13
  33. invar/shell/commands/__init__.py +11 -0
  34. invar/shell/{cli.py → commands/guard.py} +152 -44
  35. invar/shell/{init_cmd.py → commands/init.py} +200 -28
  36. invar/shell/commands/merge.py +256 -0
  37. invar/shell/commands/mutate.py +184 -0
  38. invar/shell/{perception.py → commands/perception.py} +2 -0
  39. invar/shell/commands/sync_self.py +113 -0
  40. invar/shell/commands/template_sync.py +366 -0
  41. invar/shell/{test_cmd.py → commands/test.py} +3 -1
  42. invar/shell/commands/update.py +48 -0
  43. invar/shell/config.py +247 -10
  44. invar/shell/coverage.py +351 -0
  45. invar/shell/fs.py +5 -2
  46. invar/shell/git.py +2 -0
  47. invar/shell/guard_helpers.py +116 -20
  48. invar/shell/guard_output.py +106 -24
  49. invar/shell/mcp_config.py +3 -0
  50. invar/shell/mutation.py +314 -0
  51. invar/shell/property_tests.py +75 -24
  52. invar/shell/prove/__init__.py +9 -0
  53. invar/shell/prove/accept.py +113 -0
  54. invar/shell/{prove.py → prove/crosshair.py} +69 -30
  55. invar/shell/prove/hypothesis.py +293 -0
  56. invar/shell/subprocess_env.py +393 -0
  57. invar/shell/template_engine.py +345 -0
  58. invar/shell/templates.py +53 -0
  59. invar/shell/testing.py +77 -37
  60. invar/templates/CLAUDE.md.template +86 -9
  61. invar/templates/aider.conf.yml.template +16 -14
  62. invar/templates/commands/audit.md +138 -0
  63. invar/templates/commands/guard.md +77 -0
  64. invar/templates/config/CLAUDE.md.jinja +206 -0
  65. invar/templates/config/context.md.jinja +92 -0
  66. invar/templates/config/pre-commit.yaml.jinja +44 -0
  67. invar/templates/context.md.template +33 -0
  68. invar/templates/cursorrules.template +25 -13
  69. invar/templates/examples/README.md +2 -0
  70. invar/templates/examples/conftest.py +3 -0
  71. invar/templates/examples/contracts.py +4 -2
  72. invar/templates/examples/core_shell.py +10 -4
  73. invar/templates/examples/workflow.md +81 -0
  74. invar/templates/manifest.toml +137 -0
  75. invar/templates/protocol/INVAR.md +210 -0
  76. invar/templates/skills/develop/SKILL.md.jinja +318 -0
  77. invar/templates/skills/investigate/SKILL.md.jinja +106 -0
  78. invar/templates/skills/propose/SKILL.md.jinja +104 -0
  79. invar/templates/skills/review/SKILL.md.jinja +125 -0
  80. invar_tools-1.3.0.dist-info/METADATA +377 -0
  81. invar_tools-1.3.0.dist-info/RECORD +95 -0
  82. invar_tools-1.3.0.dist-info/entry_points.txt +2 -0
  83. invar_tools-1.3.0.dist-info/licenses/LICENSE +190 -0
  84. invar_tools-1.3.0.dist-info/licenses/LICENSE-GPL +674 -0
  85. invar_tools-1.3.0.dist-info/licenses/NOTICE +63 -0
  86. invar/contracts.py +0 -152
  87. invar/decorators.py +0 -94
  88. invar/invariant.py +0 -57
  89. invar/resource.py +0 -99
  90. invar/shell/prove_fallback.py +0 -183
  91. invar/shell/update_cmd.py +0 -191
  92. invar/templates/INVAR.md +0 -134
  93. invar_tools-1.0.0.dist-info/METADATA +0 -321
  94. invar_tools-1.0.0.dist-info/RECORD +0 -64
  95. invar_tools-1.0.0.dist-info/entry_points.txt +0 -2
  96. invar_tools-1.0.0.dist-info/licenses/LICENSE +0 -21
  97. /invar/shell/{prove_cache.py → prove/cache.py} +0 -0
  98. {invar_tools-1.0.0.dist-info → invar_tools-1.3.0.dist-info}/WHEEL +0 -0
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:
@@ -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
- @post(lambda result: isinstance(result, tuple) and len(result) == 2)
52
- def _check_tautology_patterns(node: ast.expr) -> tuple[bool, str]:
53
- """Check for common tautology patterns in AST node."""
54
- # Identity comparison pattern (e.g., x == x)
55
- if (
56
- isinstance(node, ast.Compare)
57
- and len(node.ops) == 1
58
- and isinstance(node.ops[0], (ast.Eq, ast.Is))
59
- ):
60
- left = ast.unparse(node.left)
61
- right = ast.unparse(node.comparators[0])
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
- # Length non-negative pattern (e.g., len(x) >= 0)
66
- if isinstance(node, ast.Compare) and len(node.ops) == 1 and len(node.comparators) == 1:
67
- left = node.left
68
- op = node.ops[0]
69
- right = node.comparators[0]
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
- # isinstance with object pattern (always True)
82
- if (
83
- isinstance(node, ast.Call)
84
- and isinstance(node.func, ast.Name)
85
- and node.func.id == "isinstance"
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
- arg = ast.unparse(node.args[0])
91
- 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
92
112
 
93
- # Pattern: x or True, True or x (always true)
94
- if isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or):
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: isinstance(file_info, FileInfo))
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