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
invar/__init__.py CHANGED
@@ -8,7 +8,13 @@ This package provides development tools (guard, map, sig).
8
8
  For runtime contracts only, use invar-runtime instead.
9
9
  """
10
10
 
11
- __version__ = "1.0.0"
11
+ import importlib.metadata
12
+
13
+ try:
14
+ __version__ = importlib.metadata.version("invar-tools")
15
+ except importlib.metadata.PackageNotFoundError:
16
+ __version__ = "0.0.0.dev" # Development mode fallback
17
+
12
18
  __protocol_version__ = "5.0" # Protocol/spec version (separate from package version)
13
19
 
14
20
  # Re-export from invar-runtime for backwards compatibility
@@ -100,30 +100,30 @@ def count_escape_hatches(source: str) -> int:
100
100
  return len(extract_escape_hatches(source))
101
101
 
102
102
 
103
- @post(lambda result: all(len(t) == 2 for t in result)) # Returns (rule, reason) tuples
104
- def extract_escape_hatches(source: str) -> list[tuple[str, str]]:
103
+ @post(lambda result: all(len(t) == 3 for t in result)) # Returns (rule, reason, line) tuples
104
+ def extract_escape_hatches(source: str) -> list[tuple[str, str, int]]:
105
105
  """
106
- Extract @invar:allow markers with their reasons (DX-33 Option E).
106
+ Extract @invar:allow markers with their reasons and line numbers (DX-33, DX-66).
107
107
 
108
108
  Uses tokenize to only match real comments, not strings/docstrings.
109
- Returns list of (rule, reason) tuples for cross-file analysis.
109
+ Returns list of (rule, reason, line) tuples for cross-file analysis.
110
110
 
111
111
  Examples:
112
112
  >>> extract_escape_hatches("")
113
113
  []
114
114
  >>> extract_escape_hatches("# @invar:allow shell_result: API boundary")
115
- [('shell_result', 'API boundary')]
115
+ [('shell_result', 'API boundary', 1)]
116
116
  >>> source = '''
117
117
  ... # @invar:allow rule1: same reason
118
118
  ... # @invar:allow rule2: different reason
119
119
  ... '''
120
120
  >>> extract_escape_hatches(source)
121
- [('rule1', 'same reason'), ('rule2', 'different reason')]
121
+ [('rule1', 'same reason', 2), ('rule2', 'different reason', 3)]
122
122
  >>> # DX-33 Option C: Strings containing the pattern should NOT match
123
123
  >>> extract_escape_hatches('suggestion = "# @invar:allow rule: reason"')
124
124
  []
125
125
  """
126
- results: list[tuple[str, str]] = []
126
+ results: list[tuple[str, str, int]] = []
127
127
  try:
128
128
  # Use iterator-based readline to avoid io.StringIO (forbidden in Core)
129
129
  lines = iter(source.splitlines(keepends=True))
@@ -132,10 +132,12 @@ def extract_escape_hatches(source: str) -> list[tuple[str, str]]:
132
132
  if tok.type == tokenize.COMMENT:
133
133
  match = INVAR_ALLOW_PATTERN.search(tok.string)
134
134
  if match:
135
- results.append((match.group(1), match.group(2)))
135
+ # DX-66: tok.start[0] is the 1-based line number
136
+ results.append((match.group(1), match.group(2), tok.start[0]))
136
137
  except Exception:
137
- # Fall back to regex if tokenization fails (invalid syntax, non-printable chars, etc.)
138
- return INVAR_ALLOW_PATTERN.findall(source)
138
+ # Fall back to regex if tokenization fails - can't get line numbers
139
+ # Return line 0 to indicate unknown position
140
+ return [(r, reason, 0) for r, reason in INVAR_ALLOW_PATTERN.findall(source)]
139
141
  return results
140
142
 
141
143
 
invar/core/formatter.py CHANGED
@@ -235,7 +235,7 @@ def format_guard_agent(report: GuardReport, combined_status: str | None = None)
235
235
  status = combined_status if combined_status else ("passed" if report.passed else "failed")
236
236
  static_passed = report.errors == 0
237
237
 
238
- return {
238
+ result = {
239
239
  "status": status,
240
240
  # DX-26: Separate static results from combined status
241
241
  "static": {
@@ -252,6 +252,26 @@ def format_guard_agent(report: GuardReport, combined_status: str | None = None)
252
252
  },
253
253
  "fixes": [_violation_to_fix(v) for v in report.violations],
254
254
  }
255
+ # DX-61: Add suggests count if any pattern suggestions exist
256
+ if report.suggests > 0:
257
+ result["static"]["suggests"] = report.suggests
258
+ result["summary"]["suggests"] = report.suggests
259
+ # DX-66: Add escape hatch summary if any exist
260
+ if report.escape_hatches.count > 0:
261
+ result["escape_hatches"] = {
262
+ "count": report.escape_hatches.count,
263
+ "by_rule": report.escape_hatches.by_rule,
264
+ "details": [
265
+ {
266
+ "file": d.file,
267
+ "line": d.line,
268
+ "rule": d.rule,
269
+ "reason": d.reason,
270
+ }
271
+ for d in report.escape_hatches.details
272
+ ],
273
+ }
274
+ return result
255
275
 
256
276
 
257
277
  @post(lambda result: "file" in result and "rule" in result and "severity" in result)
invar/core/models.py CHANGED
@@ -28,6 +28,7 @@ class Severity(str, Enum):
28
28
  ERROR = "error"
29
29
  WARNING = "warning"
30
30
  INFO = "info" # Phase 7: For informational issues like redundant type contracts
31
+ SUGGEST = "suggest" # DX-61: Functional pattern suggestions
31
32
 
32
33
 
33
34
  class Contract(BaseModel):
@@ -82,6 +83,89 @@ class Violation(BaseModel):
82
83
  suggestion: str | None = None
83
84
 
84
85
 
86
+ class EscapeHatchDetail(BaseModel):
87
+ """
88
+ Detail of a single escape hatch (@invar:allow) marker (DX-66).
89
+
90
+ Examples:
91
+ >>> d = EscapeHatchDetail(file="test.py", line=10, rule="shell_result", reason="API")
92
+ >>> d.line
93
+ 10
94
+ >>> # line=0 is valid (fallback when line number unknown)
95
+ >>> d0 = EscapeHatchDetail(file="test.py", line=0, rule="test", reason="fallback")
96
+ >>> d0.line
97
+ 0
98
+ """
99
+
100
+ file: str
101
+ line: int = Field(ge=0) # 0 = fallback when line number unknown
102
+ rule: str
103
+ reason: str
104
+
105
+
106
+ class EscapeHatchSummary(BaseModel):
107
+ """
108
+ Summary of escape hatches in the codebase (DX-66).
109
+
110
+ Provides visibility into @invar:allow usage for tracking technical debt.
111
+
112
+ Examples:
113
+ >>> summary = EscapeHatchSummary()
114
+ >>> summary.count
115
+ 0
116
+ >>> summary.by_rule
117
+ {}
118
+ >>> detail = EscapeHatchDetail(file="test.py", line=10, rule="shell_result", reason="API boundary")
119
+ >>> summary.add(detail)
120
+ >>> summary.count
121
+ 1
122
+ >>> summary.by_rule
123
+ {'shell_result': 1}
124
+ """
125
+
126
+ details: list[EscapeHatchDetail] = Field(default_factory=list)
127
+
128
+ @property
129
+ @post(lambda result: result >= 0)
130
+ def count(self) -> int:
131
+ """
132
+ Total number of escape hatches.
133
+
134
+ Examples:
135
+ >>> EscapeHatchSummary().count
136
+ 0
137
+ """
138
+ return len(self.details)
139
+
140
+ @property
141
+ @post(lambda result: all(v >= 0 for v in result.values()))
142
+ def by_rule(self) -> dict[str, int]:
143
+ """
144
+ Count of escape hatches grouped by rule.
145
+
146
+ Examples:
147
+ >>> EscapeHatchSummary().by_rule
148
+ {}
149
+ """
150
+ counts: dict[str, int] = {}
151
+ for detail in self.details:
152
+ counts[detail.rule] = counts.get(detail.rule, 0) + 1
153
+ return counts
154
+
155
+ @pre(lambda self, detail: bool(detail.rule) and bool(detail.file))
156
+ def add(self, detail: EscapeHatchDetail) -> None:
157
+ """
158
+ Add an escape hatch detail to the summary.
159
+
160
+ Examples:
161
+ >>> s = EscapeHatchSummary()
162
+ >>> s.add(EscapeHatchDetail(file="t.py", line=1, rule="r", reason="x"))
163
+ >>> s.count
164
+ 1
165
+ """
166
+ self.details.append(detail)
167
+
168
+
85
169
  class GuardReport(BaseModel):
86
170
  """Complete Guard report for a project."""
87
171
 
@@ -90,9 +174,12 @@ class GuardReport(BaseModel):
90
174
  errors: int = 0
91
175
  warnings: int = 0
92
176
  infos: int = 0 # Phase 7: Track INFO-level issues
177
+ suggests: int = 0 # DX-61: Track SUGGEST-level pattern suggestions
93
178
  # P24: Contract coverage statistics (Core files only)
94
179
  core_functions_total: int = 0
95
180
  core_functions_with_contracts: int = 0
181
+ # DX-66: Escape hatch visibility
182
+ escape_hatches: EscapeHatchSummary = Field(default_factory=EscapeHatchSummary)
96
183
 
97
184
  @pre(lambda self, violation: violation.rule and violation.severity) # Valid violation
98
185
  def add_violation(self, violation: Violation) -> None:
@@ -106,12 +193,18 @@ class GuardReport(BaseModel):
106
193
  >>> report.add_violation(v)
107
194
  >>> report.errors
108
195
  1
196
+ >>> v2 = Violation(rule="pattern", severity=Severity.SUGGEST, file="x.py", message="sug")
197
+ >>> report.add_violation(v2)
198
+ >>> report.suggests
199
+ 1
109
200
  """
110
201
  self.violations.append(violation)
111
202
  if violation.severity == Severity.ERROR:
112
203
  self.errors += 1
113
204
  elif violation.severity == Severity.WARNING:
114
205
  self.warnings += 1
206
+ elif violation.severity == Severity.SUGGEST:
207
+ self.suggests += 1
115
208
  else:
116
209
  self.infos += 1
117
210
 
@@ -263,6 +356,11 @@ class RuleConfig(BaseModel):
263
356
  timeout_crosshair: int = Field(default=300, ge=1, le=1800) # Symbolic verification total
264
357
  timeout_crosshair_per_condition: int = Field(default=30, ge=1, le=300) # Per-contract limit
265
358
 
359
+ # DX-61: Pattern detection configuration
360
+ pattern_min_confidence: str = Field(default="medium") # low, medium, high
361
+ pattern_priorities: list[str] = Field(default_factory=lambda: ["P0"]) # P0, P1
362
+ pattern_exclude: list[str] = Field(default_factory=list) # Pattern IDs to exclude
363
+
266
364
 
267
365
  # Phase 4: Perception models
268
366
 
@@ -0,0 +1,53 @@
1
+ """
2
+ Functional Pattern Detection (DX-61).
3
+
4
+ This module provides pattern detection for suggesting functional programming
5
+ improvements in Python code. Guard integrates with this module to provide
6
+ SUGGEST-level feedback when it detects opportunities for patterns like:
7
+
8
+ P0 (Core Patterns):
9
+ - NewType: Semantic clarity for multiple same-type parameters
10
+ - Validation: Error accumulation instead of fail-fast
11
+ - NonEmpty: Compile-time non-empty guarantees
12
+ - Literal: Type-safe finite value sets
13
+ - ExhaustiveMatch: assert_never for enum matching
14
+
15
+ P1 (Extended Patterns - future):
16
+ - SmartConstructor: Validation at construction time
17
+ - StructuredError: Typed errors for programmatic handling
18
+
19
+ Usage:
20
+ >>> from invar.core.patterns import detect_patterns
21
+ >>> source = "def f(a: str, b: str, c: str): pass"
22
+ >>> result = detect_patterns("test.py", source)
23
+ >>> result.has_suggestions
24
+ True
25
+
26
+ See .invar/examples/functional.py for pattern examples.
27
+ """
28
+
29
+ from invar.core.patterns.registry import (
30
+ PatternRegistry,
31
+ detect_patterns,
32
+ get_registry,
33
+ )
34
+ from invar.core.patterns.types import (
35
+ Confidence,
36
+ DetectionResult,
37
+ Location,
38
+ PatternID,
39
+ PatternSuggestion,
40
+ Priority,
41
+ )
42
+
43
+ __all__ = [
44
+ "Confidence",
45
+ "DetectionResult",
46
+ "Location",
47
+ "PatternID",
48
+ "PatternRegistry",
49
+ "PatternSuggestion",
50
+ "Priority",
51
+ "detect_patterns",
52
+ "get_registry",
53
+ ]
@@ -0,0 +1,249 @@
1
+ """
2
+ Pattern Detector Protocol (DX-61).
3
+
4
+ Base protocol for all pattern detectors.
5
+ """
6
+
7
+ import ast
8
+ from abc import abstractmethod
9
+ from typing import Protocol
10
+
11
+ from deal import post, pre
12
+
13
+ from invar.core.patterns.types import (
14
+ Confidence,
15
+ Location,
16
+ PatternID,
17
+ PatternSuggestion,
18
+ Priority,
19
+ )
20
+
21
+
22
+ class PatternDetector(Protocol):
23
+ """
24
+ Protocol for pattern detectors.
25
+
26
+ Each detector identifies opportunities for a specific functional pattern.
27
+ Detectors analyze AST nodes and return suggestions with confidence levels.
28
+ """
29
+
30
+ @property
31
+ @abstractmethod
32
+ @post(lambda result: result in PatternID)
33
+ def pattern_id(self) -> PatternID:
34
+ """Unique identifier for this pattern."""
35
+ ...
36
+
37
+ @property
38
+ @abstractmethod
39
+ @post(lambda result: result in Priority)
40
+ def priority(self) -> Priority:
41
+ """Priority tier (P0 or P1)."""
42
+ ...
43
+
44
+ @property
45
+ @abstractmethod
46
+ @post(lambda result: len(result) > 0)
47
+ def description(self) -> str:
48
+ """Human-readable description of the pattern."""
49
+ ...
50
+
51
+ @abstractmethod
52
+ @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
53
+ def detect(self, tree: ast.AST, file_path: str) -> list[PatternSuggestion]:
54
+ """
55
+ Analyze AST and return pattern suggestions.
56
+
57
+ Args:
58
+ tree: Parsed AST of the source file
59
+ file_path: Path to the source file (for location reporting)
60
+
61
+ Returns:
62
+ List of pattern suggestions found in the file
63
+ """
64
+ ...
65
+
66
+
67
+ class BaseDetector:
68
+ """
69
+ Base class with common detection utilities.
70
+
71
+ Provides helper methods for AST analysis that can be reused
72
+ across different pattern detectors.
73
+ """
74
+
75
+ @post(lambda result: all(isinstance(name, str) and name for name, _ in result))
76
+ def get_function_params(self, node: ast.FunctionDef) -> list[tuple[str, str | None]]:
77
+ """
78
+ Extract parameter names and type annotations from a function.
79
+
80
+ >>> import ast
81
+ >>> code = "def f(a: str, b: int, c): pass"
82
+ >>> tree = ast.parse(code)
83
+ >>> func = tree.body[0]
84
+ >>> detector = BaseDetector()
85
+ >>> params = detector.get_function_params(func)
86
+ >>> params[0]
87
+ ('a', 'str')
88
+ >>> params[1]
89
+ ('b', 'int')
90
+ >>> params[2]
91
+ ('c', None)
92
+ """
93
+ params = []
94
+ for arg in node.args.args:
95
+ name = arg.arg
96
+ type_hint = None
97
+ if arg.annotation:
98
+ type_hint = self._annotation_to_str(arg.annotation)
99
+ params.append((name, type_hint))
100
+ return params
101
+
102
+ @post(lambda result: isinstance(result, str) and len(result) > 0)
103
+ def _annotation_to_str(self, annotation: ast.expr) -> str:
104
+ """
105
+ Convert an annotation AST node to string.
106
+
107
+ >>> import ast
108
+ >>> detector = BaseDetector()
109
+ >>> detector._annotation_to_str(ast.Name(id="str"))
110
+ 'str'
111
+ >>> detector._annotation_to_str(ast.Constant(value="str"))
112
+ 'str'
113
+ """
114
+ if isinstance(annotation, ast.Name):
115
+ return annotation.id
116
+ elif isinstance(annotation, ast.Constant):
117
+ return str(annotation.value)
118
+ elif isinstance(annotation, ast.Subscript):
119
+ # Handle generics like list[str]
120
+ base = self._annotation_to_str(annotation.value)
121
+ if isinstance(annotation.slice, ast.Tuple):
122
+ args = ", ".join(self._annotation_to_str(e) for e in annotation.slice.elts)
123
+ else:
124
+ args = self._annotation_to_str(annotation.slice)
125
+ return f"{base}[{args}]"
126
+ elif isinstance(annotation, ast.Attribute):
127
+ # Handle qualified names like typing.List
128
+ parts = []
129
+ node = annotation
130
+ while isinstance(node, ast.Attribute):
131
+ parts.append(node.attr)
132
+ node = node.value
133
+ if isinstance(node, ast.Name):
134
+ parts.append(node.id)
135
+ return ".".join(reversed(parts))
136
+ elif isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
137
+ # Handle X | Y union syntax
138
+ left = self._annotation_to_str(annotation.left)
139
+ right = self._annotation_to_str(annotation.right)
140
+ return f"{left} | {right}"
141
+ else:
142
+ # Python 3.9+ always has ast.unparse (project requires 3.11+)
143
+ return ast.unparse(annotation)
144
+
145
+ @pre(lambda self, params, type_name: len(type_name) > 0)
146
+ @post(lambda result: result >= 0)
147
+ def count_type_occurrences(
148
+ self, params: list[tuple[str, str | None]], type_name: str
149
+ ) -> int:
150
+ """
151
+ Count how many parameters have a specific type.
152
+
153
+ >>> detector = BaseDetector()
154
+ >>> params = [("a", "str"), ("b", "str"), ("c", "int")]
155
+ >>> detector.count_type_occurrences(params, "str")
156
+ 2
157
+ """
158
+ return sum(1 for _, t in params if t == type_name)
159
+
160
+ @post(lambda result: isinstance(result, bool))
161
+ def has_match_statement(self, node: ast.FunctionDef) -> bool:
162
+ """
163
+ Check if function contains a match statement.
164
+
165
+ >>> import ast
166
+ >>> code = '''
167
+ ... def f(x):
168
+ ... match x:
169
+ ... case 1: pass
170
+ ... '''
171
+ >>> tree = ast.parse(code)
172
+ >>> func = tree.body[0]
173
+ >>> detector = BaseDetector()
174
+ >>> detector.has_match_statement(func)
175
+ True
176
+ """
177
+ return any(isinstance(child, ast.Match) for child in ast.walk(node))
178
+
179
+ @post(lambda result: all(isinstance(c, str) for c in result))
180
+ def get_enum_cases(self, match_node: ast.Match) -> list[str]:
181
+ """
182
+ Extract case patterns from a match statement.
183
+
184
+ >>> import ast
185
+ >>> code = '''
186
+ ... match status:
187
+ ... case Status.A: pass
188
+ ... case Status.B: pass
189
+ ... '''
190
+ >>> tree = ast.parse(code)
191
+ >>> match = tree.body[0]
192
+ >>> detector = BaseDetector()
193
+ >>> cases = detector.get_enum_cases(match)
194
+ >>> "Status.A" in cases
195
+ True
196
+ """
197
+ cases = []
198
+ for case in match_node.cases:
199
+ pattern = case.pattern
200
+ if isinstance(pattern, ast.MatchValue):
201
+ cases.append(ast.unparse(pattern.value) if hasattr(ast, "unparse") else str(pattern.value))
202
+ elif isinstance(pattern, ast.MatchAs) and pattern.pattern is None:
203
+ cases.append("_") # Wildcard
204
+ return cases
205
+
206
+ @pre(lambda self, pattern_id, priority, file_path, line, message, current_code, suggested_pattern, confidence, reference_pattern: line > 0)
207
+ @post(lambda result: result.reference_file == ".invar/examples/functional.py")
208
+ def make_suggestion(
209
+ self,
210
+ pattern_id: PatternID,
211
+ priority: Priority,
212
+ file_path: str,
213
+ line: int,
214
+ message: str,
215
+ current_code: str,
216
+ suggested_pattern: str,
217
+ confidence: Confidence,
218
+ reference_pattern: str,
219
+ ) -> PatternSuggestion:
220
+ """
221
+ Create a pattern suggestion with standard reference file.
222
+
223
+ >>> from invar.core.patterns.types import PatternID, Priority, Confidence
224
+ >>> detector = BaseDetector()
225
+ >>> suggestion = detector.make_suggestion(
226
+ ... pattern_id=PatternID.NEWTYPE,
227
+ ... priority=Priority.P0,
228
+ ... file_path="test.py",
229
+ ... line=10,
230
+ ... message="Test message",
231
+ ... current_code="def f(): pass",
232
+ ... suggested_pattern="NewType",
233
+ ... confidence=Confidence.HIGH,
234
+ ... reference_pattern="Pattern 1: NewType",
235
+ ... )
236
+ >>> suggestion.reference_file
237
+ '.invar/examples/functional.py'
238
+ """
239
+ return PatternSuggestion(
240
+ pattern_id=pattern_id,
241
+ location=Location(file=file_path, line=line),
242
+ message=message,
243
+ confidence=confidence,
244
+ priority=priority,
245
+ current_code=current_code,
246
+ suggested_pattern=suggested_pattern,
247
+ reference_file=".invar/examples/functional.py",
248
+ reference_pattern=reference_pattern,
249
+ )