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/references.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reference counting for Perception (Phase 4).
|
|
3
|
+
|
|
4
|
+
This module provides functions to count cross-file symbol references.
|
|
5
|
+
Analyzes AST to find Name, Call, and Attribute nodes that reference known symbols.
|
|
6
|
+
|
|
7
|
+
No I/O operations - receives parsed data only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
|
|
15
|
+
from deal import post, pre
|
|
16
|
+
|
|
17
|
+
from invar.core.models import FileInfo, PerceptionMap, SymbolKind, SymbolRefs
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pre(lambda source, known_symbols: isinstance(source, str) and len(source) > 0)
|
|
21
|
+
@post(lambda result: isinstance(result, list))
|
|
22
|
+
def find_references_in_source(source: str, known_symbols: set[str]) -> list[tuple[str, int]]:
|
|
23
|
+
"""
|
|
24
|
+
Find references to known symbols in source code.
|
|
25
|
+
|
|
26
|
+
Returns list of (symbol_name, line_number) for each reference found.
|
|
27
|
+
Deduplicates: each (symbol, line) pair counted once.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
>>> refs = find_references_in_source("x = foo()\\nbar(x)", {"foo", "bar"})
|
|
31
|
+
>>> sorted(refs)
|
|
32
|
+
[('bar', 2), ('foo', 1)]
|
|
33
|
+
>>> find_references_in_source("x = unknown()", {"foo"})
|
|
34
|
+
[]
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
tree = ast.parse(source)
|
|
38
|
+
except (SyntaxError, TypeError, ValueError):
|
|
39
|
+
return []
|
|
40
|
+
|
|
41
|
+
seen: set[tuple[str, int]] = set()
|
|
42
|
+
|
|
43
|
+
for node in ast.walk(tree):
|
|
44
|
+
# Count function calls: foo()
|
|
45
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
|
46
|
+
name = node.func.id
|
|
47
|
+
if name in known_symbols:
|
|
48
|
+
line = getattr(node, "lineno", 0)
|
|
49
|
+
seen.add((name, line))
|
|
50
|
+
|
|
51
|
+
return list(seen)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pre(lambda file_infos: isinstance(file_infos, list))
|
|
55
|
+
@post(lambda result: isinstance(result, dict))
|
|
56
|
+
def build_symbol_table(file_infos: list[FileInfo]) -> dict[str, str]:
|
|
57
|
+
"""
|
|
58
|
+
Build a mapping of symbol names to their defining file.
|
|
59
|
+
|
|
60
|
+
Returns dict of {symbol_name: file_path}.
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind
|
|
64
|
+
>>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
65
|
+
>>> info = FileInfo(path="core/calc.py", lines=10, symbols=[sym])
|
|
66
|
+
>>> table = build_symbol_table([info])
|
|
67
|
+
>>> table["foo"]
|
|
68
|
+
'core/calc.py'
|
|
69
|
+
"""
|
|
70
|
+
symbol_table: dict[str, str] = {}
|
|
71
|
+
|
|
72
|
+
for file_info in file_infos:
|
|
73
|
+
for symbol in file_info.symbols:
|
|
74
|
+
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.CLASS):
|
|
75
|
+
# Use simple name (may have collisions, that's OK for now)
|
|
76
|
+
symbol_table[symbol.name] = file_info.path
|
|
77
|
+
|
|
78
|
+
return symbol_table
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pre(lambda file_infos, sources: isinstance(file_infos, list))
|
|
82
|
+
def count_cross_file_references(
|
|
83
|
+
file_infos: list[FileInfo], sources: dict[str, str]
|
|
84
|
+
) -> dict[str, int]:
|
|
85
|
+
"""
|
|
86
|
+
Count cross-file references for all symbols.
|
|
87
|
+
|
|
88
|
+
Returns dict of {"file::symbol": reference_count}.
|
|
89
|
+
Only counts references from OTHER files (excludes self-references).
|
|
90
|
+
|
|
91
|
+
Examples:
|
|
92
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind
|
|
93
|
+
>>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
94
|
+
>>> info = FileInfo(path="a.py", lines=10, symbols=[sym])
|
|
95
|
+
>>> sources = {"a.py": "def foo(): pass", "b.py": "foo()"}
|
|
96
|
+
>>> info2 = FileInfo(path="b.py", lines=5, symbols=[])
|
|
97
|
+
>>> refs = count_cross_file_references([info, info2], sources)
|
|
98
|
+
>>> refs.get("a.py::foo", 0)
|
|
99
|
+
1
|
|
100
|
+
"""
|
|
101
|
+
# Build symbol table: name -> defining file
|
|
102
|
+
symbol_table = build_symbol_table(file_infos)
|
|
103
|
+
known_symbols = set(symbol_table.keys())
|
|
104
|
+
|
|
105
|
+
# Count references from each file
|
|
106
|
+
ref_counts: dict[str, int] = defaultdict(int)
|
|
107
|
+
|
|
108
|
+
for file_info in file_infos:
|
|
109
|
+
source = sources.get(file_info.path, "")
|
|
110
|
+
if not source:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
references = find_references_in_source(source, known_symbols)
|
|
114
|
+
|
|
115
|
+
for symbol_name, _ in references:
|
|
116
|
+
defining_file = symbol_table.get(symbol_name)
|
|
117
|
+
if defining_file and defining_file != file_info.path:
|
|
118
|
+
# Cross-file reference: increment count
|
|
119
|
+
key = f"{defining_file}::{symbol_name}"
|
|
120
|
+
ref_counts[key] += 1
|
|
121
|
+
|
|
122
|
+
return dict(ref_counts)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pre(lambda file_infos, sources, project_root: (
|
|
126
|
+
isinstance(file_infos, list) and
|
|
127
|
+
isinstance(project_root, str) and len(project_root) > 0
|
|
128
|
+
))
|
|
129
|
+
def build_perception_map(
|
|
130
|
+
file_infos: list[FileInfo], sources: dict[str, str], project_root: str
|
|
131
|
+
) -> PerceptionMap:
|
|
132
|
+
"""
|
|
133
|
+
Build complete perception map with reference counts.
|
|
134
|
+
|
|
135
|
+
Examples:
|
|
136
|
+
>>> from invar.core.models import FileInfo, Symbol, SymbolKind
|
|
137
|
+
>>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=5)
|
|
138
|
+
>>> info = FileInfo(path="a.py", lines=10, symbols=[sym])
|
|
139
|
+
>>> pm = build_perception_map([info], {"a.py": "def foo(): pass"}, "/test")
|
|
140
|
+
>>> pm.total_symbols
|
|
141
|
+
1
|
|
142
|
+
"""
|
|
143
|
+
ref_counts = count_cross_file_references(file_infos, sources)
|
|
144
|
+
|
|
145
|
+
# Build SymbolRefs list
|
|
146
|
+
symbol_refs: list[SymbolRefs] = []
|
|
147
|
+
total_symbols = 0
|
|
148
|
+
|
|
149
|
+
for file_info in file_infos:
|
|
150
|
+
for symbol in file_info.symbols:
|
|
151
|
+
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.CLASS):
|
|
152
|
+
key = f"{file_info.path}::{symbol.name}"
|
|
153
|
+
count = ref_counts.get(key, 0)
|
|
154
|
+
symbol_refs.append(
|
|
155
|
+
SymbolRefs(
|
|
156
|
+
symbol=symbol,
|
|
157
|
+
file_path=file_info.path,
|
|
158
|
+
ref_count=count,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
total_symbols += 1
|
|
162
|
+
|
|
163
|
+
# Sort by reference count (descending)
|
|
164
|
+
symbol_refs.sort(key=lambda sr: sr.ref_count, reverse=True)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
return PerceptionMap(
|
|
168
|
+
project_root=project_root,
|
|
169
|
+
total_files=len(file_infos),
|
|
170
|
+
total_symbols=total_symbols,
|
|
171
|
+
symbols=symbol_refs,
|
|
172
|
+
)
|
|
173
|
+
except Exception:
|
|
174
|
+
# Handle CrossHair symbolic value validation failures
|
|
175
|
+
return PerceptionMap(
|
|
176
|
+
project_root="",
|
|
177
|
+
total_files=0,
|
|
178
|
+
total_symbols=0,
|
|
179
|
+
symbols=[],
|
|
180
|
+
)
|
invar/core/rule_meta.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized rule metadata for Guard rules.
|
|
3
|
+
|
|
4
|
+
Phase 9.2 P3: Single source of truth for rule information.
|
|
5
|
+
Used by: hints (P5), --agent output, invar rules command.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from deal import post
|
|
14
|
+
|
|
15
|
+
from invar.core.models import Severity
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RuleCategory(str, Enum):
|
|
19
|
+
"""Categories for grouping rules."""
|
|
20
|
+
|
|
21
|
+
SIZE = "size"
|
|
22
|
+
CONTRACTS = "contracts"
|
|
23
|
+
PURITY = "purity"
|
|
24
|
+
SHELL = "shell"
|
|
25
|
+
DOCS = "docs"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class RuleMeta:
|
|
30
|
+
"""
|
|
31
|
+
Metadata for a Guard rule.
|
|
32
|
+
|
|
33
|
+
Examples:
|
|
34
|
+
>>> meta = RULE_META["file_size"]
|
|
35
|
+
>>> meta.category
|
|
36
|
+
<RuleCategory.SIZE: 'size'>
|
|
37
|
+
>>> "Split" in meta.hint
|
|
38
|
+
True
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
severity: Severity
|
|
43
|
+
category: RuleCategory
|
|
44
|
+
detects: str
|
|
45
|
+
cannot_detect: tuple[str, ...]
|
|
46
|
+
hint: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# All rule metadata definitions
|
|
50
|
+
RULE_META: dict[str, RuleMeta] = {
|
|
51
|
+
# Size rules
|
|
52
|
+
"file_size": RuleMeta(
|
|
53
|
+
name="file_size",
|
|
54
|
+
severity=Severity.ERROR,
|
|
55
|
+
category=RuleCategory.SIZE,
|
|
56
|
+
detects="File exceeds max_file_lines limit",
|
|
57
|
+
cannot_detect=("Code complexity", "Logical cohesion", "Coupling between modules"),
|
|
58
|
+
hint="Split into smaller modules by responsibility",
|
|
59
|
+
),
|
|
60
|
+
"file_size_warning": RuleMeta(
|
|
61
|
+
name="file_size_warning",
|
|
62
|
+
severity=Severity.WARNING,
|
|
63
|
+
category=RuleCategory.SIZE,
|
|
64
|
+
detects="File approaching max_file_lines limit (80% threshold)",
|
|
65
|
+
cannot_detect=("Whether split is actually needed",),
|
|
66
|
+
hint="Consider splitting before reaching limit",
|
|
67
|
+
),
|
|
68
|
+
"function_size": RuleMeta(
|
|
69
|
+
name="function_size",
|
|
70
|
+
severity=Severity.WARNING,
|
|
71
|
+
category=RuleCategory.SIZE,
|
|
72
|
+
detects="Function exceeds max_function_lines limit",
|
|
73
|
+
cannot_detect=("Algorithm complexity", "Whether extraction helps readability"),
|
|
74
|
+
hint="Extract helper functions or simplify logic",
|
|
75
|
+
),
|
|
76
|
+
# Contract rules
|
|
77
|
+
"missing_contract": RuleMeta(
|
|
78
|
+
name="missing_contract",
|
|
79
|
+
severity=Severity.ERROR,
|
|
80
|
+
category=RuleCategory.CONTRACTS,
|
|
81
|
+
detects="Core function without @pre or @post decorator",
|
|
82
|
+
cannot_detect=("Contract quality", "Whether contract is meaningful"),
|
|
83
|
+
hint="Ask: what inputs are invalid? what does output guarantee?",
|
|
84
|
+
),
|
|
85
|
+
"empty_contract": RuleMeta(
|
|
86
|
+
name="empty_contract",
|
|
87
|
+
severity=Severity.ERROR,
|
|
88
|
+
category=RuleCategory.CONTRACTS,
|
|
89
|
+
detects="Contract with tautology like @pre(lambda: True)",
|
|
90
|
+
cannot_detect=("Subtle tautologies", "Semantic correctness"),
|
|
91
|
+
hint="Replace lambda: True with actual constraint on inputs/output",
|
|
92
|
+
),
|
|
93
|
+
"redundant_type_contract": RuleMeta(
|
|
94
|
+
name="redundant_type_contract",
|
|
95
|
+
severity=Severity.INFO,
|
|
96
|
+
category=RuleCategory.CONTRACTS,
|
|
97
|
+
detects="Contract only checks types already in annotations",
|
|
98
|
+
cannot_detect=("Whether better constraints exist",),
|
|
99
|
+
hint="Add value constraints beyond type checks if possible",
|
|
100
|
+
),
|
|
101
|
+
"param_mismatch": RuleMeta(
|
|
102
|
+
name="param_mismatch",
|
|
103
|
+
severity=Severity.ERROR,
|
|
104
|
+
category=RuleCategory.CONTRACTS,
|
|
105
|
+
detects="@pre lambda parameters don't match function signature",
|
|
106
|
+
cannot_detect=("Runtime binding errors",),
|
|
107
|
+
hint="Lambda must accept ALL function parameters (include defaults like x=10)",
|
|
108
|
+
),
|
|
109
|
+
"must_use_ignored": RuleMeta(
|
|
110
|
+
name="must_use_ignored",
|
|
111
|
+
severity=Severity.WARNING,
|
|
112
|
+
category=RuleCategory.CONTRACTS,
|
|
113
|
+
detects="Return value of @must_use function is ignored",
|
|
114
|
+
cannot_detect=("Cross-module must_use", "Dynamic function references"),
|
|
115
|
+
hint="Assign or use the return value - it may contain errors or resources",
|
|
116
|
+
),
|
|
117
|
+
# Purity rules
|
|
118
|
+
"forbidden_import": RuleMeta(
|
|
119
|
+
name="forbidden_import",
|
|
120
|
+
severity=Severity.ERROR,
|
|
121
|
+
category=RuleCategory.PURITY,
|
|
122
|
+
detects="Core module imports I/O libraries (os, sys, pathlib, etc.)",
|
|
123
|
+
cannot_detect=("Transitive imports", "Dynamic imports"),
|
|
124
|
+
hint="Move I/O operations to Shell layer",
|
|
125
|
+
),
|
|
126
|
+
"internal_import": RuleMeta(
|
|
127
|
+
name="internal_import",
|
|
128
|
+
severity=Severity.WARNING,
|
|
129
|
+
category=RuleCategory.PURITY,
|
|
130
|
+
detects="Import statement inside function body",
|
|
131
|
+
cannot_detect=("Lazy imports for performance", "Circular import workarounds"),
|
|
132
|
+
hint="Move import to module top-level or justify with comment",
|
|
133
|
+
),
|
|
134
|
+
"impure_call": RuleMeta(
|
|
135
|
+
name="impure_call",
|
|
136
|
+
severity=Severity.ERROR,
|
|
137
|
+
category=RuleCategory.PURITY,
|
|
138
|
+
detects="Call to impure function (datetime.now, random.*, print, open)",
|
|
139
|
+
cannot_detect=("Custom impure functions", "Impurity via method calls"),
|
|
140
|
+
hint="Inject impure values as parameters or move to Shell",
|
|
141
|
+
),
|
|
142
|
+
# Shell rules
|
|
143
|
+
"shell_result": RuleMeta(
|
|
144
|
+
name="shell_result",
|
|
145
|
+
severity=Severity.WARNING,
|
|
146
|
+
category=RuleCategory.SHELL,
|
|
147
|
+
detects="Shell function not returning Result[T, E]",
|
|
148
|
+
cannot_detect=("Result usage correctness", "Error handling quality"),
|
|
149
|
+
hint="Wrap return value with Success() or return Failure()",
|
|
150
|
+
),
|
|
151
|
+
# Documentation rules
|
|
152
|
+
"missing_doctest": RuleMeta(
|
|
153
|
+
name="missing_doctest",
|
|
154
|
+
severity=Severity.WARNING,
|
|
155
|
+
category=RuleCategory.DOCS,
|
|
156
|
+
detects="Core function without doctest examples",
|
|
157
|
+
cannot_detect=("Doctest quality", "Edge case coverage"),
|
|
158
|
+
hint="Add >>> examples showing typical usage and edge cases",
|
|
159
|
+
),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@post(lambda result: result is None or isinstance(result, RuleMeta))
|
|
164
|
+
def get_rule_meta(rule_name: str) -> RuleMeta | None:
|
|
165
|
+
"""
|
|
166
|
+
Get metadata for a rule by name.
|
|
167
|
+
|
|
168
|
+
Examples:
|
|
169
|
+
>>> meta = get_rule_meta("file_size")
|
|
170
|
+
>>> meta is not None
|
|
171
|
+
True
|
|
172
|
+
>>> get_rule_meta("nonexistent") is None
|
|
173
|
+
True
|
|
174
|
+
"""
|
|
175
|
+
return RULE_META.get(rule_name)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@post(lambda result: len(result) > 0)
|
|
179
|
+
def get_all_rule_names() -> list[str]:
|
|
180
|
+
"""
|
|
181
|
+
Get list of all rule names.
|
|
182
|
+
|
|
183
|
+
Examples:
|
|
184
|
+
>>> names = get_all_rule_names()
|
|
185
|
+
>>> "file_size" in names
|
|
186
|
+
True
|
|
187
|
+
>>> len(names) >= 10
|
|
188
|
+
True
|
|
189
|
+
"""
|
|
190
|
+
return list(RULE_META.keys())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@post(lambda result: isinstance(result, list))
|
|
194
|
+
def get_rules_by_category(category: RuleCategory) -> list[RuleMeta]:
|
|
195
|
+
"""
|
|
196
|
+
Get all rules in a category.
|
|
197
|
+
|
|
198
|
+
Examples:
|
|
199
|
+
>>> size_rules = get_rules_by_category(RuleCategory.SIZE)
|
|
200
|
+
>>> len(size_rules) >= 2
|
|
201
|
+
True
|
|
202
|
+
"""
|
|
203
|
+
return [meta for meta in RULE_META.values() if meta.category == category]
|