invar-tools 1.0.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 (64) hide show
  1. invar/__init__.py +68 -0
  2. invar/contracts.py +152 -0
  3. invar/core/__init__.py +8 -0
  4. invar/core/contracts.py +375 -0
  5. invar/core/extraction.py +172 -0
  6. invar/core/formatter.py +281 -0
  7. invar/core/hypothesis_strategies.py +454 -0
  8. invar/core/inspect.py +154 -0
  9. invar/core/lambda_helpers.py +190 -0
  10. invar/core/models.py +289 -0
  11. invar/core/must_use.py +172 -0
  12. invar/core/parser.py +276 -0
  13. invar/core/property_gen.py +383 -0
  14. invar/core/purity.py +369 -0
  15. invar/core/purity_heuristics.py +184 -0
  16. invar/core/references.py +180 -0
  17. invar/core/rule_meta.py +203 -0
  18. invar/core/rules.py +435 -0
  19. invar/core/strategies.py +267 -0
  20. invar/core/suggestions.py +324 -0
  21. invar/core/tautology.py +137 -0
  22. invar/core/timeout_inference.py +114 -0
  23. invar/core/utils.py +364 -0
  24. invar/decorators.py +94 -0
  25. invar/invariant.py +57 -0
  26. invar/mcp/__init__.py +10 -0
  27. invar/mcp/__main__.py +13 -0
  28. invar/mcp/server.py +251 -0
  29. invar/py.typed +0 -0
  30. invar/resource.py +99 -0
  31. invar/shell/__init__.py +8 -0
  32. invar/shell/cli.py +358 -0
  33. invar/shell/config.py +248 -0
  34. invar/shell/fs.py +112 -0
  35. invar/shell/git.py +85 -0
  36. invar/shell/guard_helpers.py +324 -0
  37. invar/shell/guard_output.py +235 -0
  38. invar/shell/init_cmd.py +289 -0
  39. invar/shell/mcp_config.py +171 -0
  40. invar/shell/perception.py +125 -0
  41. invar/shell/property_tests.py +227 -0
  42. invar/shell/prove.py +460 -0
  43. invar/shell/prove_cache.py +133 -0
  44. invar/shell/prove_fallback.py +183 -0
  45. invar/shell/templates.py +443 -0
  46. invar/shell/test_cmd.py +117 -0
  47. invar/shell/testing.py +297 -0
  48. invar/shell/update_cmd.py +191 -0
  49. invar/templates/CLAUDE.md.template +58 -0
  50. invar/templates/INVAR.md +134 -0
  51. invar/templates/__init__.py +1 -0
  52. invar/templates/aider.conf.yml.template +29 -0
  53. invar/templates/context.md.template +51 -0
  54. invar/templates/cursorrules.template +28 -0
  55. invar/templates/examples/README.md +21 -0
  56. invar/templates/examples/contracts.py +111 -0
  57. invar/templates/examples/core_shell.py +121 -0
  58. invar/templates/pre-commit-config.yaml.template +44 -0
  59. invar/templates/proposal.md.template +93 -0
  60. invar_tools-1.0.0.dist-info/METADATA +321 -0
  61. invar_tools-1.0.0.dist-info/RECORD +64 -0
  62. invar_tools-1.0.0.dist-info/WHEEL +4 -0
  63. invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
  64. invar_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,172 @@
1
+ """Extraction analysis for Guard (Phase 11 P25). No I/O operations.
2
+
3
+ Analyzes function call relationships to suggest extractable groups
4
+ when files approach size limits.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from deal import post, pre
10
+
11
+ from invar.core.models import FileInfo, Symbol, SymbolKind
12
+
13
+
14
+ @pre(lambda funcs: isinstance(funcs, dict))
15
+ @post(lambda result: isinstance(result, dict))
16
+ def _build_call_graph(funcs: dict[str, Symbol]) -> dict[str, set[str]]:
17
+ """Build bidirectional call graph for function grouping.
18
+
19
+ >>> from invar.core.models import Symbol, SymbolKind
20
+ >>> s = Symbol(name="a", kind=SymbolKind.FUNCTION, line=1, end_line=5, function_calls=["b"])
21
+ >>> g = _build_call_graph({"a": s})
22
+ >>> "a" in g
23
+ True
24
+ """
25
+ func_names = set(funcs.keys())
26
+ graph: dict[str, set[str]] = {name: set() for name in func_names}
27
+
28
+ for name, sym in funcs.items():
29
+ for called in sym.function_calls:
30
+ if called in func_names:
31
+ graph[name].add(called)
32
+ graph[called].add(name)
33
+ return graph
34
+
35
+
36
+ @pre(lambda start, graph, visited: start and isinstance(graph, dict) and start in graph)
37
+ @post(lambda result: isinstance(result, list))
38
+ def _find_connected_component(start: str, graph: dict[str, set[str]], visited: set[str]) -> list[str]:
39
+ """BFS to find all functions connected to start.
40
+
41
+ >>> g = {"a": {"b"}, "b": {"a"}, "c": set()}
42
+ >>> v = set()
43
+ >>> _find_connected_component("a", g, v)
44
+ ['a', 'b']
45
+ """
46
+ component: list[str] = []
47
+ queue = [start]
48
+ while queue:
49
+ current = queue.pop(0)
50
+ if current in visited or current not in graph:
51
+ continue
52
+ visited.add(current)
53
+ component.append(current)
54
+ queue.extend(n for n in graph[current] if n not in visited)
55
+ return component
56
+
57
+
58
+ @pre(lambda file_info: isinstance(file_info, FileInfo))
59
+ def find_extractable_groups(file_info: FileInfo) -> list[dict]:
60
+ """
61
+ Find groups of related functions that could be extracted together.
62
+
63
+ Uses function call relationships to identify connected components.
64
+ Returns groups sorted by total lines (largest first).
65
+
66
+ Examples:
67
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind
68
+ >>> s1 = Symbol(name="main", kind=SymbolKind.FUNCTION, line=1, end_line=20,
69
+ ... function_calls=["helper"])
70
+ >>> s2 = Symbol(name="helper", kind=SymbolKind.FUNCTION, line=21, end_line=30,
71
+ ... function_calls=[])
72
+ >>> s3 = Symbol(name="unrelated", kind=SymbolKind.FUNCTION, line=31, end_line=40,
73
+ ... function_calls=[])
74
+ >>> info = FileInfo(path="test.py", lines=40, symbols=[s1, s2, s3])
75
+ >>> groups = find_extractable_groups(info)
76
+ >>> len(groups)
77
+ 2
78
+ >>> sorted(groups[0]["functions"]) # Largest group first
79
+ ['helper', 'main']
80
+ >>> groups[0]["lines"]
81
+ 30
82
+ """
83
+ funcs = {
84
+ s.name: s for s in file_info.symbols if s.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD)
85
+ }
86
+ if not funcs:
87
+ return []
88
+
89
+ graph = _build_call_graph(funcs)
90
+ visited: set[str] = set()
91
+ groups: list[dict] = []
92
+
93
+ for name in funcs:
94
+ if name in visited:
95
+ continue
96
+
97
+ component = _find_connected_component(name, graph, visited)
98
+ total_lines = sum(funcs[n].end_line - funcs[n].line + 1 for n in component)
99
+ deps = _get_group_dependencies(component, funcs, file_info.imports)
100
+
101
+ groups.append({
102
+ "functions": sorted(component),
103
+ "lines": total_lines,
104
+ "dependencies": sorted(deps),
105
+ })
106
+
107
+ groups.sort(key=lambda g: -g["lines"])
108
+ return groups
109
+
110
+
111
+ @pre(lambda func_names, funcs, file_imports: all(n in funcs for n in func_names if n))
112
+ @post(lambda result: isinstance(result, set))
113
+ def _get_group_dependencies(
114
+ func_names: list[str],
115
+ funcs: dict[str, Symbol],
116
+ file_imports: list[str],
117
+ ) -> set[str]:
118
+ """Get external dependencies used by a group of functions.
119
+
120
+ >>> from invar.core.models import Symbol, SymbolKind
121
+ >>> s = Symbol(name="f", kind=SymbolKind.FUNCTION, line=1, end_line=5, internal_imports=["os"])
122
+ >>> _get_group_dependencies(["f"], {"f": s}, ["os", "sys"])
123
+ {'os'}
124
+ """
125
+ deps: set[str] = set()
126
+
127
+ for name in func_names:
128
+ if not name or name not in funcs:
129
+ continue
130
+ sym = funcs[name]
131
+ # Add internal imports used by this function
132
+ deps.update(sym.internal_imports)
133
+
134
+ # Filter to only include actual imports from file
135
+ # (some internal_imports might be from nested scopes)
136
+ return deps.intersection(set(file_imports)) if file_imports else deps
137
+
138
+
139
+ @pre(lambda file_info, max_groups=3: isinstance(file_info, FileInfo))
140
+ def format_extraction_hint(file_info: FileInfo, max_groups: int = 3) -> str:
141
+ """
142
+ Format extraction suggestions for file_size_warning.
143
+
144
+ P25: Shows extractable function groups with dependencies.
145
+
146
+ Examples:
147
+ >>> from invar.core.models import FileInfo, Symbol, SymbolKind
148
+ >>> s1 = Symbol(name="parse", kind=SymbolKind.FUNCTION, line=1, end_line=50,
149
+ ... function_calls=["validate"], internal_imports=["ast"])
150
+ >>> s2 = Symbol(name="validate", kind=SymbolKind.FUNCTION, line=51, end_line=80,
151
+ ... function_calls=[], internal_imports=["ast"])
152
+ >>> info = FileInfo(path="test.py", lines=100, symbols=[s1, s2], imports=["ast", "re"])
153
+ >>> hint = format_extraction_hint(info)
154
+ >>> "parse, validate" in hint
155
+ True
156
+ >>> "(80L)" in hint
157
+ True
158
+ """
159
+ groups = find_extractable_groups(file_info)
160
+
161
+ if not groups:
162
+ return ""
163
+
164
+ # Format top N groups
165
+ hints: list[str] = []
166
+ for i, group in enumerate(groups[:max_groups]):
167
+ funcs = ", ".join(group["functions"])
168
+ lines = group["lines"]
169
+ deps = ", ".join(group["dependencies"]) if group["dependencies"] else "none"
170
+ hints.append(f"[{chr(65 + i)}] {funcs} ({lines}L) | Deps: {deps}")
171
+
172
+ return "\n".join(hints)
@@ -0,0 +1,281 @@
1
+ """
2
+ Output formatting for Perception (Phase 4) and Guard (Phase 8).
3
+
4
+ This module provides functions to format perception and guard output.
5
+ Supports both human-readable (Rich) and machine-readable (JSON) formats.
6
+
7
+ No I/O operations - returns formatted strings/dicts only.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from deal import post, pre
13
+
14
+ from invar.core.models import GuardReport, PerceptionMap, Symbol, SymbolRefs, Violation
15
+ from invar.core.rule_meta import get_rule_meta
16
+
17
+
18
+ @pre(lambda perception_map, top_n=0: isinstance(perception_map, PerceptionMap))
19
+ def format_map_text(perception_map: PerceptionMap, top_n: int = 0) -> str:
20
+ """
21
+ Format perception map as plain text.
22
+
23
+ Args:
24
+ perception_map: The perception map to format
25
+ top_n: If > 0, only show top N symbols by reference count
26
+
27
+ Examples:
28
+ >>> from invar.core.models import PerceptionMap
29
+ >>> pm = PerceptionMap(project_root="/test", total_files=1, total_symbols=0)
30
+ >>> "Project:" in format_map_text(pm)
31
+ True
32
+ """
33
+ lines: list[str] = []
34
+ lines.append(f"Project: {perception_map.project_root}")
35
+ lines.append(f"Files: {perception_map.total_files}")
36
+ lines.append(f"Symbols: {perception_map.total_symbols}")
37
+ lines.append("")
38
+
39
+ symbols = perception_map.symbols
40
+ if top_n > 0:
41
+ symbols = symbols[:top_n]
42
+
43
+ if not symbols:
44
+ lines.append("No symbols found.")
45
+ return "\n".join(lines)
46
+
47
+ # Group by reference count level
48
+ hot = [s for s in symbols if s.ref_count > 10]
49
+ warm = [s for s in symbols if 3 <= s.ref_count <= 10]
50
+ cold = [s for s in symbols if s.ref_count < 3]
51
+
52
+ for label, group, level in [
53
+ ("Hot (refs > 10)", hot, "hot"),
54
+ ("Warm (refs 3-10)", warm, "warm"),
55
+ ("Cold (refs < 3)", cold, "cold"),
56
+ ]:
57
+ if group:
58
+ lines.append(f"=== {label} ===")
59
+ for sr in group:
60
+ lines.extend(_format_symbol_detail(sr, level=level))
61
+ lines.append("")
62
+
63
+ return "\n".join(lines)
64
+
65
+
66
+ @pre(lambda sr, level: isinstance(sr, SymbolRefs) and level in ("hot", "warm", "cold"))
67
+ @post(lambda result: all(isinstance(line, str) for line in result))
68
+ def _format_symbol_detail(sr: SymbolRefs, level: str) -> list[str]:
69
+ """Format a single symbol with appropriate detail level."""
70
+ lines: list[str] = []
71
+ sym = sr.symbol
72
+ sig = sym.signature or f"({sym.name})"
73
+
74
+ if level == "hot":
75
+ # Full detail: signature + docstring + contracts
76
+ lines.append(f" {sr.file_path}::{sym.name}{sig} [refs: {sr.ref_count}]")
77
+ if sym.docstring:
78
+ first_line = sym.docstring.split("\n")[0].strip()
79
+ if first_line:
80
+ lines.append(f" | {first_line}")
81
+ if sym.contracts:
82
+ for c in sym.contracts:
83
+ lines.append(f" | @{c.kind}: {c.expression}")
84
+ elif level == "warm":
85
+ # Medium: signature + contracts summary
86
+ lines.append(f" {sr.file_path}::{sym.name}{sig} [refs: {sr.ref_count}]")
87
+ if sym.contracts:
88
+ kinds = [c.kind for c in sym.contracts]
89
+ lines.append(f" | contracts: {', '.join(kinds)}")
90
+ else:
91
+ # Minimal: name only
92
+ lines.append(f" {sr.file_path}::{sym.name} [refs: {sr.ref_count}]")
93
+
94
+ return lines
95
+
96
+
97
+ @pre(lambda perception_map, top_n=0: isinstance(perception_map, PerceptionMap) and top_n >= 0)
98
+ def format_map_json(perception_map: PerceptionMap, top_n: int = 0) -> dict:
99
+ """
100
+ Format perception map as JSON-serializable dict.
101
+
102
+ Args:
103
+ perception_map: The perception map to format.
104
+ top_n: Limit to top N symbols by ref_count. 0 means all symbols.
105
+
106
+ Examples:
107
+ >>> from invar.core.models import PerceptionMap
108
+ >>> pm = PerceptionMap(project_root="/test", total_files=1, total_symbols=0)
109
+ >>> d = format_map_json(pm)
110
+ >>> d["project_root"]
111
+ '/test'
112
+ """
113
+ symbols = perception_map.symbols
114
+ if top_n > 0:
115
+ symbols = symbols[:top_n]
116
+ return {
117
+ "project_root": perception_map.project_root,
118
+ "total_files": perception_map.total_files,
119
+ "total_symbols": perception_map.total_symbols,
120
+ "symbols": [_symbol_refs_to_dict(sr) for sr in symbols],
121
+ }
122
+
123
+
124
+ @pre(lambda sr: isinstance(sr, SymbolRefs))
125
+ @post(lambda result: "name" in result and "ref_count" in result)
126
+ def _symbol_refs_to_dict(sr: SymbolRefs) -> dict:
127
+ """Convert SymbolRefs to dict."""
128
+ sym = sr.symbol
129
+ return {
130
+ "file": sr.file_path,
131
+ "name": sym.name,
132
+ "kind": sym.kind.value,
133
+ "line": sym.line,
134
+ "signature": sym.signature,
135
+ "ref_count": sr.ref_count,
136
+ "docstring": sym.docstring,
137
+ "contracts": [{"kind": c.kind, "expression": c.expression} for c in sym.contracts],
138
+ }
139
+
140
+
141
+ @pre(lambda symbol, file_path: isinstance(symbol, Symbol))
142
+ def format_signature(symbol: Symbol, file_path: str) -> str:
143
+ """
144
+ Format a single symbol signature.
145
+
146
+ Examples:
147
+ >>> from invar.core.models import Symbol, SymbolKind
148
+ >>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=5,
149
+ ... signature="(x: int) -> int")
150
+ >>> format_signature(sym, "test.py")
151
+ 'test.py::foo(x: int) -> int'
152
+ """
153
+ sig = symbol.signature or ""
154
+ return f"{file_path}::{symbol.name}{sig}"
155
+
156
+
157
+ @pre(lambda symbols, file_path: isinstance(symbols, list))
158
+ def format_signatures_text(symbols: list[Symbol], file_path: str) -> str:
159
+ """
160
+ Format multiple signatures as text.
161
+
162
+ Examples:
163
+ >>> from invar.core.models import Symbol, SymbolKind
164
+ >>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=5)
165
+ >>> format_signatures_text([sym], "test.py")
166
+ 'test.py::foo'
167
+ """
168
+ lines = [format_signature(sym, file_path) for sym in symbols]
169
+ return "\n".join(lines)
170
+
171
+
172
+ @pre(lambda symbols, file_path: isinstance(symbols, list))
173
+ def format_signatures_json(symbols: list[Symbol], file_path: str) -> dict:
174
+ """
175
+ Format signatures as JSON-serializable dict.
176
+
177
+ Examples:
178
+ >>> from invar.core.models import Symbol, SymbolKind
179
+ >>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=5)
180
+ >>> d = format_signatures_json([sym], "test.py")
181
+ >>> d["file"]
182
+ 'test.py'
183
+ """
184
+ return {
185
+ "file": file_path,
186
+ "symbols": [
187
+ {
188
+ "name": sym.name,
189
+ "kind": sym.kind.value,
190
+ "line": sym.line,
191
+ "signature": sym.signature,
192
+ "docstring": sym.docstring,
193
+ "contracts": [{"kind": c.kind, "expression": c.expression} for c in sym.contracts],
194
+ }
195
+ for sym in symbols
196
+ ],
197
+ }
198
+
199
+
200
+ # Phase 8.2: Agent-mode formatting
201
+
202
+
203
+ @pre(lambda report: isinstance(report, GuardReport))
204
+ def format_guard_agent(report: GuardReport) -> dict:
205
+ """
206
+ Format Guard report for Agent consumption (Phase 8.2).
207
+
208
+ Provides structured output with actionable fix instructions.
209
+
210
+ Examples:
211
+ >>> from invar.core.models import GuardReport, Violation, Severity
212
+ >>> report = GuardReport(files_checked=1)
213
+ >>> v = Violation(rule="missing_contract", severity=Severity.WARNING,
214
+ ... file="test.py", line=10, message="Function 'foo' has no contract",
215
+ ... suggestion="Add: @pre(lambda x: x >= 0)")
216
+ >>> report.add_violation(v)
217
+ >>> d = format_guard_agent(report)
218
+ >>> d["status"]
219
+ 'passed'
220
+ >>> len(d["fixes"])
221
+ 1
222
+ """
223
+ return {
224
+ "status": "passed" if report.passed else "failed",
225
+ "summary": {
226
+ "files_checked": report.files_checked,
227
+ "errors": report.errors,
228
+ "warnings": report.warnings,
229
+ "infos": report.infos,
230
+ },
231
+ "fixes": [_violation_to_fix(v) for v in report.violations],
232
+ }
233
+
234
+
235
+ @pre(lambda v: isinstance(v, Violation))
236
+ def _violation_to_fix(v: Violation) -> dict:
237
+ """Convert a Violation to an Agent-friendly fix instruction."""
238
+ fix_info = _parse_suggestion(v.suggestion, v.rule) if v.suggestion else None
239
+
240
+ # Phase 9.2 P3: Include rule metadata
241
+ result: dict = {
242
+ "file": v.file,
243
+ "line": v.line,
244
+ "rule": v.rule,
245
+ "severity": v.severity.value,
246
+ "message": v.message,
247
+ "fix": fix_info,
248
+ }
249
+
250
+ meta = get_rule_meta(v.rule)
251
+ if meta:
252
+ result["rule_meta"] = {
253
+ "category": meta.category.value,
254
+ "detects": meta.detects,
255
+ "cannot_detect": list(meta.cannot_detect),
256
+ "hint": meta.hint,
257
+ }
258
+
259
+ return result
260
+
261
+
262
+ @pre(lambda suggestion, rule: suggestion is None or isinstance(suggestion, str))
263
+ def _parse_suggestion(suggestion: str | None, rule: str) -> dict | None:
264
+ """Parse suggestion string into structured fix instruction."""
265
+ if not suggestion:
266
+ return None
267
+
268
+ # Parse "Add: @pre(...)" style suggestions
269
+ if suggestion.startswith("Add: "):
270
+ return {"action": "add_decorator", "code": suggestion[5:]}
271
+
272
+ # Parse "Replace with: @pre(...)" style suggestions
273
+ if suggestion.startswith("Replace with: "):
274
+ return {"action": "replace_decorator", "code": suggestion[14:]}
275
+
276
+ # Parse "Replace with business logic: @pre(...)" style
277
+ if suggestion.startswith("Replace with business logic: "):
278
+ return {"action": "replace_decorator", "code": suggestion[29:]}
279
+
280
+ # Default: return as instruction text
281
+ return {"action": "manual", "instruction": suggestion}