invar-tools 1.4.0__py3-none-any.whl → 1.6.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 (34) hide show
  1. invar/__init__.py +7 -1
  2. invar/core/entry_points.py +12 -10
  3. invar/core/formatter.py +21 -1
  4. invar/core/models.py +98 -0
  5. invar/core/patterns/__init__.py +53 -0
  6. invar/core/patterns/detector.py +249 -0
  7. invar/core/patterns/p0_exhaustive.py +207 -0
  8. invar/core/patterns/p0_literal.py +307 -0
  9. invar/core/patterns/p0_newtype.py +211 -0
  10. invar/core/patterns/p0_nonempty.py +307 -0
  11. invar/core/patterns/p0_validation.py +278 -0
  12. invar/core/patterns/registry.py +234 -0
  13. invar/core/patterns/types.py +167 -0
  14. invar/core/trivial_detection.py +189 -0
  15. invar/mcp/server.py +4 -0
  16. invar/shell/commands/guard.py +100 -8
  17. invar/shell/config.py +46 -0
  18. invar/shell/contract_coverage.py +358 -0
  19. invar/shell/guard_output.py +15 -0
  20. invar/shell/pattern_integration.py +234 -0
  21. invar/shell/testing.py +13 -2
  22. invar/templates/CLAUDE.md.template +18 -10
  23. invar/templates/config/CLAUDE.md.jinja +52 -30
  24. invar/templates/config/context.md.jinja +14 -0
  25. invar/templates/protocol/INVAR.md +1 -0
  26. invar/templates/skills/develop/SKILL.md.jinja +51 -1
  27. invar/templates/skills/review/SKILL.md.jinja +196 -31
  28. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/METADATA +12 -8
  29. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/RECORD +34 -22
  30. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/WHEEL +0 -0
  31. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/entry_points.txt +0 -0
  32. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE +0 -0
  33. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/LICENSE-GPL +0 -0
  34. {invar_tools-1.4.0.dist-info → invar_tools-1.6.0.dist-info}/licenses/NOTICE +0 -0
@@ -0,0 +1,189 @@
1
+ """Trivial contract detection for DX-63.
2
+
3
+ Pure logic module - no I/O. Detects contracts that provide no real constraint.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+ from deal import post, pre
13
+
14
+
15
+ @dataclass
16
+ class TrivialContract:
17
+ """A trivial contract that provides no real constraint.
18
+
19
+ Examples:
20
+ >>> tc = TrivialContract(
21
+ ... file="src/core/calc.py",
22
+ ... line=10,
23
+ ... function_name="process",
24
+ ... contract_type="post",
25
+ ... expression="lambda: True"
26
+ ... )
27
+ >>> tc.contract_type
28
+ 'post'
29
+ """
30
+
31
+ file: str
32
+ line: int
33
+ function_name: str
34
+ contract_type: str # "pre" or "post"
35
+ expression: str
36
+
37
+
38
+ # Patterns that match trivial contracts
39
+ TRIVIAL_PATTERNS: list[re.Pattern[str]] = [
40
+ re.compile(r"^\s*lambda\s*:\s*True\s*$"), # lambda: True
41
+ re.compile(r"^\s*lambda\s+\w+\s*:\s*True\s*$"), # lambda x: True
42
+ re.compile(r"^\s*lambda\s+[\w,\s]+:\s*True\s*$"), # lambda x, y: True
43
+ re.compile(r"^\s*lambda\s+\*\w+\s*:\s*True\s*$"), # lambda *args: True
44
+ re.compile(r"^\s*lambda\s+\*\*\w+\s*:\s*True\s*$"), # lambda **kwargs: True
45
+ re.compile(r"^\s*lambda\s+result\s*:\s*True\s*$"), # lambda result: True
46
+ re.compile(r"^\s*lambda\s+_\s*:\s*True\s*$"), # lambda _: True
47
+ ]
48
+
49
+
50
+ @pre(lambda expression: len(expression.strip()) > 0)
51
+ @post(lambda result: isinstance(result, bool))
52
+ def is_trivial_contract(expression: str) -> bool:
53
+ """Check if a contract expression is trivial (provides no constraint).
54
+
55
+ Trivial contracts always return True regardless of input, providing
56
+ no actual constraint on the function's behavior.
57
+
58
+ Examples:
59
+ >>> is_trivial_contract("lambda: True")
60
+ True
61
+ >>> is_trivial_contract("lambda x: True")
62
+ True
63
+ >>> is_trivial_contract("lambda x, y: True")
64
+ True
65
+ >>> is_trivial_contract("lambda result: True")
66
+ True
67
+ >>> is_trivial_contract("lambda x: x > 0")
68
+ False
69
+ >>> is_trivial_contract("lambda items: len(items) > 0")
70
+ False
71
+ >>> is_trivial_contract("lambda result: result >= 0")
72
+ False
73
+ """
74
+ expr = expression.strip()
75
+ return any(pattern.match(expr) for pattern in TRIVIAL_PATTERNS)
76
+
77
+
78
+ @pre(lambda node: isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)))
79
+ @post(lambda result: all(t[0] in ("pre", "post") for t in result))
80
+ def extract_contracts_from_decorators(
81
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
82
+ ) -> list[tuple[str, str]]:
83
+ """Extract contract expressions from function decorators.
84
+
85
+ Returns list of (contract_type, expression) tuples.
86
+
87
+ Examples:
88
+ >>> import ast
89
+ >>> code = '''
90
+ ... @pre(lambda x: x > 0)
91
+ ... @post(lambda result: result >= 0)
92
+ ... def calc(x): return x * 2
93
+ ... '''
94
+ >>> tree = ast.parse(code)
95
+ >>> func = tree.body[0]
96
+ >>> contracts = extract_contracts_from_decorators(func)
97
+ >>> len(contracts)
98
+ 2
99
+ >>> contracts[0][0]
100
+ 'pre'
101
+ """
102
+ contracts = []
103
+
104
+ for decorator in node.decorator_list:
105
+ if isinstance(decorator, ast.Call):
106
+ # Get decorator name
107
+ if isinstance(decorator.func, ast.Name):
108
+ name = decorator.func.id
109
+ elif isinstance(decorator.func, ast.Attribute):
110
+ name = decorator.func.attr
111
+ else:
112
+ continue
113
+
114
+ # Check if it's a contract decorator
115
+ if name in ("pre", "post"):
116
+ # Get the first argument (the lambda or condition)
117
+ if decorator.args:
118
+ arg = decorator.args[0]
119
+ if isinstance(arg, ast.Lambda):
120
+ # Convert lambda back to source
121
+ expr = ast.unparse(arg)
122
+ contracts.append((name, expr))
123
+
124
+ return contracts
125
+
126
+
127
+ @pre(lambda source, file_path: len(source) >= 0 and len(file_path) > 0)
128
+ @post(lambda result: result[0] >= 0 and result[1] >= 0 and result[1] <= result[0])
129
+ def analyze_contracts_in_source(
130
+ source: str, file_path: str
131
+ ) -> tuple[int, int, list[TrivialContract]]:
132
+ """Analyze contracts in Python source code.
133
+
134
+ Pure function - receives source as string, no file I/O.
135
+
136
+ Returns: (total_functions, functions_with_contracts, trivial_contracts)
137
+
138
+ Examples:
139
+ >>> source = '''
140
+ ... from deal import pre, post
141
+ ... @pre(lambda x: x > 0)
142
+ ... def good(x): return x
143
+ ... def no_contract(x): return x
144
+ ... @post(lambda: True)
145
+ ... def trivial(x): return x
146
+ ... '''
147
+ >>> total, with_c, trivials = analyze_contracts_in_source(source, "test.py")
148
+ >>> total
149
+ 3
150
+ >>> with_c
151
+ 2
152
+ >>> len(trivials)
153
+ 1
154
+ """
155
+ try:
156
+ tree = ast.parse(source)
157
+ except SyntaxError:
158
+ return (0, 0, [])
159
+
160
+ total_functions = 0
161
+ functions_with_contracts = 0
162
+ trivial_contracts: list[TrivialContract] = []
163
+
164
+ for node in ast.walk(tree):
165
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
166
+ # Skip private/dunder methods
167
+ if node.name.startswith("_"):
168
+ continue
169
+
170
+ total_functions += 1
171
+ contracts = extract_contracts_from_decorators(node)
172
+
173
+ if contracts:
174
+ functions_with_contracts += 1
175
+
176
+ # Check for trivial contracts
177
+ for contract_type, expr in contracts:
178
+ if is_trivial_contract(expr):
179
+ trivial_contracts.append(
180
+ TrivialContract(
181
+ file=file_path,
182
+ line=node.lineno,
183
+ function_name=node.name,
184
+ contract_type=contract_type,
185
+ expression=expr,
186
+ )
187
+ )
188
+
189
+ return (total_functions, functions_with_contracts, trivial_contracts)
invar/mcp/server.py CHANGED
@@ -137,6 +137,7 @@ def _get_guard_tool() -> Tool:
137
137
  "changed": {"type": "boolean", "description": "Only verify git-changed files", "default": True},
138
138
  "strict": {"type": "boolean", "description": "Treat warnings as errors", "default": False},
139
139
  "coverage": {"type": "boolean", "description": "DX-37: Collect branch coverage from doctest + hypothesis", "default": False},
140
+ "contracts_only": {"type": "boolean", "description": "DX-63: Contract coverage check only (skip tests)", "default": False},
140
141
  },
141
142
  },
142
143
  )
@@ -223,6 +224,9 @@ async def _run_guard(args: dict[str, Any]) -> list[TextContent]:
223
224
  # DX-37: Optional coverage collection
224
225
  if args.get("coverage", False):
225
226
  cmd.append("--coverage")
227
+ # DX-63: Contract coverage check only
228
+ if args.get("contracts_only", False):
229
+ cmd.append("--contracts-only")
226
230
 
227
231
  # DX-26: TTY auto-detection - MCP runs in non-TTY, so agent JSON output is automatic
228
232
  # No explicit flag needed
@@ -25,7 +25,7 @@ from invar import __version__
25
25
  from invar.core.models import GuardReport, RuleConfig
26
26
  from invar.core.rules import check_all_rules
27
27
  from invar.core.utils import get_exit_code
28
- from invar.shell.config import load_config
28
+ from invar.shell.config import find_project_root, load_config
29
29
  from invar.shell.fs import scan_project
30
30
  from invar.shell.guard_output import output_agent, output_rich
31
31
 
@@ -56,13 +56,13 @@ def _count_core_functions(file_info) -> tuple[int, int]:
56
56
  return (total, with_contracts)
57
57
 
58
58
 
59
- # @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
60
59
  # @shell_complexity: Core orchestration - iterates files, handles failures, aggregates results
61
60
  def _scan_and_check(
62
61
  path: Path, config: RuleConfig, only_files: set[Path] | None = None
63
62
  ) -> Result[GuardReport, str]:
64
63
  """Scan project files and check against rules."""
65
64
  from invar.core.entry_points import extract_escape_hatches
65
+ from invar.core.models import EscapeHatchDetail
66
66
  from invar.core.review_trigger import check_duplicate_escape_reasons
67
67
  from invar.core.shell_architecture import check_complexity_debt
68
68
 
@@ -80,10 +80,17 @@ def _scan_and_check(
80
80
  report.update_coverage(total, with_contracts)
81
81
  for violation in check_all_rules(file_info, config):
82
82
  report.add_violation(violation)
83
- # DX-33: Collect escape hatches for cross-file analysis
83
+ # DX-33 + DX-66: Collect escape hatches for cross-file analysis and visibility
84
84
  if file_info.source:
85
- for rule, reason in extract_escape_hatches(file_info.source):
85
+ for rule, reason, line in extract_escape_hatches(file_info.source):
86
86
  all_escapes.append((file_info.path, rule, reason))
87
+ # DX-66: Add to escape hatch summary
88
+ report.escape_hatches.add(EscapeHatchDetail(
89
+ file=file_info.path,
90
+ line=line,
91
+ rule=rule,
92
+ reason=reason,
93
+ ))
87
94
 
88
95
  # DX-22: Check project-level complexity debt (Fix-or-Explain enforcement)
89
96
  for debt_violation in check_complexity_debt(
@@ -102,7 +109,11 @@ def _scan_and_check(
102
109
  @app.command()
103
110
  def guard(
104
111
  path: Path = typer.Argument(
105
- Path(), help="Project root directory", exists=True, file_okay=False, dir_okay=True
112
+ Path(),
113
+ help="Project directory or single Python file",
114
+ exists=True,
115
+ file_okay=True,
116
+ dir_okay=True,
106
117
  ),
107
118
  strict: bool = typer.Option(False, "--strict", help="Treat warnings as errors"),
108
119
  changed: bool = typer.Option(
@@ -133,11 +144,19 @@ def guard(
133
144
  coverage: bool = typer.Option(
134
145
  False, "--coverage", help="DX-37: Collect branch coverage from doctest + hypothesis"
135
146
  ),
147
+ suggest: bool = typer.Option(
148
+ False, "--suggest", help="DX-61: Show functional pattern suggestions"
149
+ ),
150
+ contracts_only: bool = typer.Option(
151
+ False, "--contracts-only", "-c", help="DX-63: Contract coverage check only"
152
+ ),
136
153
  ) -> None:
137
154
  """Check project against Invar architecture rules.
138
155
 
139
156
  Smart Guard: Runs static analysis + doctests + CrossHair + Hypothesis by default.
140
157
  Use --static for quick static-only checks (~0.5s vs ~5s full).
158
+ Use --suggest to get functional pattern suggestions (NewType, Validation, etc.).
159
+ Use --contracts-only (-c) to check contract coverage without running tests (DX-63).
141
160
  """
142
161
  from invar.shell.guard_helpers import (
143
162
  collect_files_to_check,
@@ -149,6 +168,16 @@ def guard(
149
168
  )
150
169
  from invar.shell.testing import VerificationLevel
151
170
 
171
+ # DX-65: Handle single file mode
172
+ single_file_mode = path.is_file()
173
+ single_file: Path | None = None
174
+ if single_file_mode:
175
+ if path.suffix != ".py":
176
+ console.print(f"[red]Error:[/red] {path} is not a Python file")
177
+ raise typer.Exit(1)
178
+ single_file = path.resolve()
179
+ path = find_project_root(path)
180
+
152
181
  # Load and configure
153
182
  config_result = load_config(path)
154
183
  if isinstance(config_result, Failure):
@@ -161,10 +190,41 @@ def guard(
161
190
  if pedantic:
162
191
  config.severity_overrides = {}
163
192
 
164
- # Handle --changed mode
193
+ # DX-63: Contract coverage check only mode
194
+ if contracts_only:
195
+ import json
196
+
197
+ from invar.shell.contract_coverage import (
198
+ calculate_contract_coverage,
199
+ format_contract_coverage_agent,
200
+ format_contract_coverage_report,
201
+ )
202
+
203
+ # DX-65: Use single file path if in single file mode
204
+ coverage_path = single_file if single_file else path
205
+ coverage_result = calculate_contract_coverage(coverage_path, changed_only=changed)
206
+ if isinstance(coverage_result, Failure):
207
+ console.print(f"[red]Error:[/red] {coverage_result.failure()}")
208
+ raise typer.Exit(1)
209
+
210
+ report_data = coverage_result.unwrap()
211
+ use_agent_output = _determine_output_mode(human, agent, json_output)
212
+
213
+ if use_agent_output:
214
+ console.print(json.dumps(format_contract_coverage_agent(report_data)))
215
+ else:
216
+ console.print(format_contract_coverage_report(report_data))
217
+
218
+ raise typer.Exit(0 if report_data.ready_for_build else 1)
219
+
220
+ # Handle --changed mode or single file mode (DX-65)
165
221
  only_files: set[Path] | None = None
166
222
  checked_files: list[Path] = []
167
- if changed:
223
+ if single_file:
224
+ # DX-65: Single file mode - only check the specified file
225
+ only_files = {single_file}
226
+ checked_files = [single_file]
227
+ elif changed:
168
228
  changed_result = handle_changed_mode(path)
169
229
  if isinstance(changed_result, Failure):
170
230
  if changed_result.failure() == "NO_CHANGES":
@@ -181,6 +241,25 @@ def guard(
181
241
  raise typer.Exit(1)
182
242
  report = scan_result.unwrap()
183
243
 
244
+ # DX-61: Run pattern detection if --suggest flag is set
245
+ pattern_suggestions: list = []
246
+ if suggest:
247
+ from invar.shell.pattern_integration import (
248
+ filter_suggestions,
249
+ run_pattern_detection,
250
+ suggestions_to_violations,
251
+ )
252
+ # Run pattern detection on checked files
253
+ files_to_check = list(only_files) if only_files else None
254
+ pattern_result = run_pattern_detection(path, files_to_check)
255
+ if isinstance(pattern_result, Success):
256
+ raw_suggestions = pattern_result.unwrap()
257
+ # DX-61: Apply config-based filtering
258
+ pattern_suggestions = filter_suggestions(raw_suggestions, config)
259
+ # Add suggestions to report as SUGGEST-level violations
260
+ for violation in suggestions_to_violations(pattern_suggestions):
261
+ report.add_violation(violation)
262
+
184
263
  # DX-26: Simplified output mode (TTY auto-detect + --human override)
185
264
  use_agent_output = _determine_output_mode(human, agent, json_output)
186
265
 
@@ -327,7 +406,7 @@ def _show_verification_level(verification_level) -> None:
327
406
  @app.command()
328
407
  def version() -> None:
329
408
  """Show Invar version."""
330
- console.print(f"invar {__version__}")
409
+ console.print(f"invar-tools {__version__}")
331
410
 
332
411
 
333
412
  @app.command("map")
@@ -464,5 +543,18 @@ app.add_typer(dev_app)
464
543
  app.command("sync-self", hidden=True)(sync_self)
465
544
 
466
545
 
546
+ # MCP server command for Claude Code integration
547
+ @app.command()
548
+ def mcp() -> None:
549
+ """Start Invar MCP server for AI agent integration.
550
+
551
+ This runs the MCP server using stdio transport.
552
+ Used by Claude Code and other MCP-compatible AI agents.
553
+ """
554
+ from invar.mcp.server import run_server
555
+
556
+ run_server()
557
+
558
+
467
559
  if __name__ == "__main__":
468
560
  app()
invar/shell/config.py CHANGED
@@ -267,6 +267,52 @@ def _find_config_source(project_root: Path) -> Result[tuple[Path | None, ConfigS
267
267
  return Failure(f"Failed to find config: {e}")
268
268
 
269
269
 
270
+ # @shell_complexity: Project root discovery requires checking multiple markers
271
+ def find_project_root(start_path: "Path") -> "Path": # noqa: UP037
272
+ """
273
+ Find project root by walking up from start_path looking for config files.
274
+
275
+ Looks for (in order): pyproject.toml, invar.toml, .invar/, .git/
276
+
277
+ Args:
278
+ start_path: Starting path (file or directory)
279
+
280
+ Returns:
281
+ Project root directory (absolute path), or start_path's parent if no markers found
282
+
283
+ Examples:
284
+ >>> from pathlib import Path
285
+ >>> import tempfile
286
+ >>> with tempfile.TemporaryDirectory() as tmpdir:
287
+ ... root = Path(tmpdir).resolve()
288
+ ... (root / "pyproject.toml").touch()
289
+ ... subdir = root / "src" / "core"
290
+ ... subdir.mkdir(parents=True)
291
+ ... found = find_project_root(subdir / "file.py")
292
+ ... found == root
293
+ True
294
+ """
295
+ from pathlib import Path
296
+
297
+ current = Path(start_path).resolve() # Resolve to absolute path
298
+ if current.is_file():
299
+ current = current.parent
300
+
301
+ # Walk up looking for project markers
302
+ for parent in [current, *current.parents]:
303
+ if (parent / "pyproject.toml").exists():
304
+ return parent
305
+ if (parent / "invar.toml").exists():
306
+ return parent
307
+ if (parent / ".invar").is_dir():
308
+ return parent
309
+ if (parent / ".git").exists():
310
+ return parent
311
+
312
+ # Fallback to the starting directory
313
+ return current
314
+
315
+
270
316
  def _read_toml(path: Path) -> Result[dict[str, Any], str]:
271
317
  """Read and parse a TOML file."""
272
318
  try: