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.
- invar/__init__.py +68 -0
- invar/contracts.py +152 -0
- invar/core/__init__.py +8 -0
- invar/core/contracts.py +375 -0
- invar/core/extraction.py +172 -0
- invar/core/formatter.py +281 -0
- invar/core/hypothesis_strategies.py +454 -0
- invar/core/inspect.py +154 -0
- invar/core/lambda_helpers.py +190 -0
- invar/core/models.py +289 -0
- invar/core/must_use.py +172 -0
- invar/core/parser.py +276 -0
- invar/core/property_gen.py +383 -0
- invar/core/purity.py +369 -0
- invar/core/purity_heuristics.py +184 -0
- invar/core/references.py +180 -0
- invar/core/rule_meta.py +203 -0
- invar/core/rules.py +435 -0
- invar/core/strategies.py +267 -0
- invar/core/suggestions.py +324 -0
- invar/core/tautology.py +137 -0
- invar/core/timeout_inference.py +114 -0
- invar/core/utils.py +364 -0
- invar/decorators.py +94 -0
- invar/invariant.py +57 -0
- invar/mcp/__init__.py +10 -0
- invar/mcp/__main__.py +13 -0
- invar/mcp/server.py +251 -0
- invar/py.typed +0 -0
- invar/resource.py +99 -0
- invar/shell/__init__.py +8 -0
- invar/shell/cli.py +358 -0
- invar/shell/config.py +248 -0
- invar/shell/fs.py +112 -0
- invar/shell/git.py +85 -0
- invar/shell/guard_helpers.py +324 -0
- invar/shell/guard_output.py +235 -0
- invar/shell/init_cmd.py +289 -0
- invar/shell/mcp_config.py +171 -0
- invar/shell/perception.py +125 -0
- invar/shell/property_tests.py +227 -0
- invar/shell/prove.py +460 -0
- invar/shell/prove_cache.py +133 -0
- invar/shell/prove_fallback.py +183 -0
- invar/shell/templates.py +443 -0
- invar/shell/test_cmd.py +117 -0
- invar/shell/testing.py +297 -0
- invar/shell/update_cmd.py +191 -0
- invar/templates/CLAUDE.md.template +58 -0
- invar/templates/INVAR.md +134 -0
- invar/templates/__init__.py +1 -0
- invar/templates/aider.conf.yml.template +29 -0
- invar/templates/context.md.template +51 -0
- invar/templates/cursorrules.template +28 -0
- invar/templates/examples/README.md +21 -0
- invar/templates/examples/contracts.py +111 -0
- invar/templates/examples/core_shell.py +121 -0
- invar/templates/pre-commit-config.yaml.template +44 -0
- invar/templates/proposal.md.template +93 -0
- invar_tools-1.0.0.dist-info/METADATA +321 -0
- invar_tools-1.0.0.dist-info/RECORD +64 -0
- invar_tools-1.0.0.dist-info/WHEEL +4 -0
- invar_tools-1.0.0.dist-info/entry_points.txt +2 -0
- invar_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
invar/core/extraction.py
ADDED
|
@@ -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)
|
invar/core/formatter.py
ADDED
|
@@ -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}
|