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,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
+ )
@@ -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]