invar-tools 1.8.0__py3-none-any.whl → 1.10.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 (110) hide show
  1. invar/__init__.py +8 -0
  2. invar/core/language.py +88 -0
  3. invar/core/models.py +106 -0
  4. invar/core/patterns/detector.py +6 -1
  5. invar/core/patterns/p0_exhaustive.py +15 -3
  6. invar/core/patterns/p0_literal.py +15 -3
  7. invar/core/patterns/p0_newtype.py +15 -3
  8. invar/core/patterns/p0_nonempty.py +15 -3
  9. invar/core/patterns/p0_validation.py +15 -3
  10. invar/core/patterns/registry.py +5 -1
  11. invar/core/patterns/types.py +5 -1
  12. invar/core/property_gen.py +4 -0
  13. invar/core/rules.py +84 -18
  14. invar/core/sync_helpers.py +27 -1
  15. invar/core/ts_parsers.py +286 -0
  16. invar/core/ts_sig_parser.py +307 -0
  17. invar/node_tools/MANIFEST +7 -0
  18. invar/node_tools/__init__.py +51 -0
  19. invar/node_tools/fc-runner/cli.js +77 -0
  20. invar/node_tools/quick-check/cli.js +28 -0
  21. invar/node_tools/ts-analyzer/cli.js +480 -0
  22. invar/shell/claude_hooks.py +35 -12
  23. invar/shell/commands/guard.py +36 -1
  24. invar/shell/commands/init.py +82 -3
  25. invar/shell/commands/perception.py +157 -33
  26. invar/shell/commands/skill.py +187 -0
  27. invar/shell/commands/template_sync.py +65 -13
  28. invar/shell/commands/uninstall.py +60 -12
  29. invar/shell/commands/update.py +6 -14
  30. invar/shell/contract_coverage.py +1 -0
  31. invar/shell/fs.py +66 -13
  32. invar/shell/pi_hooks.py +6 -0
  33. invar/shell/prove/guard_ts.py +899 -0
  34. invar/shell/skill_manager.py +353 -0
  35. invar/shell/template_engine.py +28 -4
  36. invar/shell/templates.py +4 -4
  37. invar/templates/claude-md/python/critical-rules.md +33 -0
  38. invar/templates/claude-md/python/quick-reference.md +24 -0
  39. invar/templates/claude-md/typescript/critical-rules.md +40 -0
  40. invar/templates/claude-md/typescript/quick-reference.md +24 -0
  41. invar/templates/claude-md/universal/check-in.md +25 -0
  42. invar/templates/claude-md/universal/skills.md +73 -0
  43. invar/templates/claude-md/universal/workflow.md +55 -0
  44. invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
  45. invar/templates/config/AGENT.md.jinja +58 -0
  46. invar/templates/config/CLAUDE.md.jinja +16 -209
  47. invar/templates/config/context.md.jinja +19 -0
  48. invar/templates/examples/{README.md → python/README.md} +2 -0
  49. invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
  50. invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
  51. invar/templates/examples/python/core_shell.py +227 -0
  52. invar/templates/examples/python/functional.py +613 -0
  53. invar/templates/examples/typescript/README.md +31 -0
  54. invar/templates/examples/typescript/contracts.ts +163 -0
  55. invar/templates/examples/typescript/core_shell.ts +374 -0
  56. invar/templates/examples/typescript/functional.ts +601 -0
  57. invar/templates/examples/typescript/workflow.md +95 -0
  58. invar/templates/hooks/PostToolUse.sh.jinja +10 -1
  59. invar/templates/hooks/PreToolUse.sh.jinja +38 -0
  60. invar/templates/hooks/Stop.sh.jinja +1 -1
  61. invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
  62. invar/templates/hooks/pi/invar.ts.jinja +9 -0
  63. invar/templates/manifest.toml +7 -6
  64. invar/templates/onboard/assessment.md.jinja +214 -0
  65. invar/templates/onboard/patterns/python.md +347 -0
  66. invar/templates/onboard/patterns/typescript.md +452 -0
  67. invar/templates/onboard/roadmap.md.jinja +168 -0
  68. invar/templates/protocol/INVAR.md.jinja +51 -0
  69. invar/templates/protocol/python/architecture-examples.md +41 -0
  70. invar/templates/protocol/python/contracts-syntax.md +56 -0
  71. invar/templates/protocol/python/markers.md +44 -0
  72. invar/templates/protocol/python/tools.md +24 -0
  73. invar/templates/protocol/python/troubleshooting.md +38 -0
  74. invar/templates/protocol/typescript/architecture-examples.md +52 -0
  75. invar/templates/protocol/typescript/contracts-syntax.md +73 -0
  76. invar/templates/protocol/typescript/markers.md +48 -0
  77. invar/templates/protocol/typescript/tools.md +65 -0
  78. invar/templates/protocol/typescript/troubleshooting.md +104 -0
  79. invar/templates/protocol/universal/architecture.md +36 -0
  80. invar/templates/protocol/universal/completion.md +14 -0
  81. invar/templates/protocol/universal/contracts-concept.md +37 -0
  82. invar/templates/protocol/universal/header.md +17 -0
  83. invar/templates/protocol/universal/session.md +17 -0
  84. invar/templates/protocol/universal/six-laws.md +10 -0
  85. invar/templates/protocol/universal/usbv.md +14 -0
  86. invar/templates/protocol/universal/visible-workflow.md +25 -0
  87. invar/templates/skills/develop/SKILL.md.jinja +39 -3
  88. invar/templates/skills/extensions/_registry.yaml +93 -0
  89. invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
  90. invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
  91. invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
  92. invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
  93. invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
  94. invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
  95. invar/templates/skills/extensions/security/SKILL.md +382 -0
  96. invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
  97. invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
  98. invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
  99. invar/templates/skills/review/SKILL.md.jinja +331 -71
  100. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/METADATA +304 -12
  101. invar_tools-1.10.0.dist-info/RECORD +173 -0
  102. invar/templates/examples/core_shell.py +0 -127
  103. invar/templates/protocol/INVAR.md +0 -310
  104. invar_tools-1.8.0.dist-info/RECORD +0 -116
  105. /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
  106. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/WHEEL +0 -0
  107. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/entry_points.txt +0 -0
  108. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE +0 -0
  109. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE-GPL +0 -0
  110. {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/licenses/NOTICE +0 -0
invar/__init__.py CHANGED
@@ -32,6 +32,7 @@ from invar_runtime import (
32
32
  NoNone,
33
33
  Percentage,
34
34
  Positive,
35
+ RelationViolation,
35
36
  ResourceWarning,
36
37
  Sorted,
37
38
  SortedNonEmpty,
@@ -42,8 +43,11 @@ from invar_runtime import (
42
43
  must_use,
43
44
  post,
44
45
  pre,
46
+ relates,
47
+ relates_multi,
45
48
  skip_property_test,
46
49
  strategy,
50
+ to_post_contract,
47
51
  )
48
52
 
49
53
  __all__ = [
@@ -60,6 +64,7 @@ __all__ = [
60
64
  "NonNegative",
61
65
  "Percentage",
62
66
  "Positive",
67
+ "RelationViolation",
63
68
  "ResourceWarning",
64
69
  "Sorted",
65
70
  "SortedNonEmpty",
@@ -70,6 +75,9 @@ __all__ = [
70
75
  "must_use",
71
76
  "post",
72
77
  "pre",
78
+ "relates",
79
+ "relates_multi",
73
80
  "skip_property_test",
74
81
  "strategy",
82
+ "to_post_contract",
75
83
  ]
invar/core/language.py ADDED
@@ -0,0 +1,88 @@
1
+ """Language detection for multi-language support.
2
+
3
+ This module provides language detection based on project marker files.
4
+ Part of LX-05 language-agnostic architecture.
5
+ """
6
+
7
+ from deal import post, pre
8
+
9
+ # Supported languages for Invar verification
10
+ SUPPORTED_LANGUAGES: frozenset[str] = frozenset({"python", "typescript"})
11
+
12
+ # Future languages (detected but not yet supported)
13
+ FUTURE_LANGUAGES: frozenset[str] = frozenset({"rust", "go"})
14
+
15
+ # All detectable languages
16
+ ALL_DETECTABLE: frozenset[str] = SUPPORTED_LANGUAGES | FUTURE_LANGUAGES
17
+
18
+
19
+ @pre(lambda markers: isinstance(markers, (set, frozenset)))
20
+ @post(lambda result: result in ALL_DETECTABLE)
21
+ def detect_language_from_markers(markers: frozenset[str]) -> str:
22
+ """Detect project language from marker file names (pure logic).
23
+
24
+ Args:
25
+ markers: Set of marker file names present in the project root.
26
+
27
+ Returns:
28
+ Detected language identifier string.
29
+
30
+ Examples:
31
+ >>> detect_language_from_markers(frozenset({"pyproject.toml"}))
32
+ 'python'
33
+
34
+ >>> detect_language_from_markers(frozenset({"setup.py", "README.md"}))
35
+ 'python'
36
+
37
+ >>> detect_language_from_markers(frozenset({"tsconfig.json"}))
38
+ 'typescript'
39
+
40
+ >>> detect_language_from_markers(frozenset({"package.json"}))
41
+ 'typescript'
42
+
43
+ >>> detect_language_from_markers(frozenset({"Cargo.toml"}))
44
+ 'rust'
45
+
46
+ >>> detect_language_from_markers(frozenset({"go.mod"}))
47
+ 'go'
48
+
49
+ >>> detect_language_from_markers(frozenset()) # Empty defaults to python
50
+ 'python'
51
+
52
+ >>> detect_language_from_markers(frozenset({"README.md"})) # Unknown defaults to python
53
+ 'python'
54
+ """
55
+ # Detection order matters - first match wins
56
+ if "pyproject.toml" in markers or "setup.py" in markers:
57
+ return "python"
58
+ if "tsconfig.json" in markers or "package.json" in markers:
59
+ return "typescript"
60
+ if "Cargo.toml" in markers:
61
+ return "rust"
62
+ if "go.mod" in markers:
63
+ return "go"
64
+ return "python" # Default
65
+
66
+
67
+ @pre(lambda lang: isinstance(lang, str) and len(lang) > 0)
68
+ @post(lambda result: isinstance(result, bool))
69
+ def is_supported(lang: str) -> bool:
70
+ """Check if a language is currently supported for verification.
71
+
72
+ Args:
73
+ lang: Language identifier to check.
74
+
75
+ Returns:
76
+ True if the language has full Invar support.
77
+
78
+ Examples:
79
+ >>> is_supported("python")
80
+ True
81
+ >>> is_supported("typescript")
82
+ True
83
+ >>> is_supported("rust")
84
+ False
85
+ >>> is_supported("go")
86
+ False
87
+ """
88
+ return lang in SUPPORTED_LANGUAGES
invar/core/models.py CHANGED
@@ -1,3 +1,4 @@
1
+ # @invar:allow file_size: LX-10 added layer types and functions, extraction planned
1
2
  """
2
3
  Pydantic models for Invar.
3
4
 
@@ -7,6 +8,7 @@ No I/O operations allowed.
7
8
 
8
9
  from __future__ import annotations
9
10
 
11
+ from dataclasses import dataclass
10
12
  from enum import Enum
11
13
  from typing import Literal
12
14
 
@@ -31,6 +33,47 @@ class Severity(str, Enum):
31
33
  SUGGEST = "suggest" # DX-61: Functional pattern suggestions
32
34
 
33
35
 
36
+ class CodeLayer(str, Enum):
37
+ """Code layer for differentiated size limits (LX-10)."""
38
+
39
+ CORE = "core"
40
+ SHELL = "shell"
41
+ TESTS = "tests"
42
+ DEFAULT = "default"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class LayerLimits:
47
+ """Size limits for a specific code layer (LX-10).
48
+
49
+ Examples:
50
+ >>> limits = LayerLimits(max_file_lines=500, max_function_lines=50)
51
+ >>> limits.max_file_lines
52
+ 500
53
+ >>> limits.max_function_lines
54
+ 50
55
+ """
56
+
57
+ max_file_lines: int
58
+ max_function_lines: int
59
+
60
+
61
+ # LX-10: Hardcoded layer limits (no config needed)
62
+ PYTHON_LAYER_LIMITS: dict[CodeLayer, LayerLimits] = {
63
+ CodeLayer.CORE: LayerLimits(500, 50),
64
+ CodeLayer.SHELL: LayerLimits(700, 100),
65
+ CodeLayer.TESTS: LayerLimits(1000, 200),
66
+ CodeLayer.DEFAULT: LayerLimits(600, 80),
67
+ }
68
+
69
+ TYPESCRIPT_LAYER_LIMITS: dict[CodeLayer, LayerLimits] = {
70
+ CodeLayer.CORE: LayerLimits(650, 65),
71
+ CodeLayer.SHELL: LayerLimits(910, 130),
72
+ CodeLayer.TESTS: LayerLimits(1300, 260),
73
+ CodeLayer.DEFAULT: LayerLimits(780, 104),
74
+ }
75
+
76
+
34
77
  class Contract(BaseModel):
35
78
  """A contract (precondition or postcondition) on a function."""
36
79
 
@@ -72,6 +115,69 @@ class FileInfo(BaseModel):
72
115
  source: str = "" # Original source code for advanced analysis
73
116
 
74
117
 
118
+ # LX-10: Layer detection functions
119
+ @pre(lambda file_info: file_info is not None and hasattr(file_info, "path"))
120
+ @post(lambda result: result in CodeLayer)
121
+ def get_layer(file_info: FileInfo) -> CodeLayer:
122
+ """
123
+ Determine code layer from FileInfo classification.
124
+
125
+ Uses existing is_core/is_shell fields. Tests detection via path.
126
+
127
+ Examples:
128
+ >>> get_layer(FileInfo(path="src/core/logic.py", lines=10, is_core=True))
129
+ <CodeLayer.CORE: 'core'>
130
+ >>> get_layer(FileInfo(path="src/shell/cli.py", lines=10, is_shell=True))
131
+ <CodeLayer.SHELL: 'shell'>
132
+ >>> get_layer(FileInfo(path="tests/test_foo.py", lines=10))
133
+ <CodeLayer.TESTS: 'tests'>
134
+ >>> get_layer(FileInfo(path="src/utils.py", lines=10))
135
+ <CodeLayer.DEFAULT: 'default'>
136
+ >>> # Edge: "test_" must be at filename start, not anywhere in path
137
+ >>> get_layer(FileInfo(path="src/contest_utils.py", lines=10))
138
+ <CodeLayer.DEFAULT: 'default'>
139
+ >>> get_layer(FileInfo(path="src/foo_test.py", lines=10))
140
+ <CodeLayer.TESTS: 'tests'>
141
+ """
142
+ # Tests: path-based (no is_tests field exists)
143
+ path_lower = file_info.path.replace("\\", "/").lower()
144
+ filename = path_lower.rsplit("/", 1)[-1] # Extract filename
145
+ # Match: /tests/ dir, /test/ dir, test_*.py files, *_test.py files
146
+ if (
147
+ "/tests/" in path_lower
148
+ or "/test/" in path_lower
149
+ or filename.startswith("test_")
150
+ or filename.endswith("_test.py")
151
+ ):
152
+ return CodeLayer.TESTS
153
+
154
+ # Core/Shell: use existing classification
155
+ if file_info.is_core:
156
+ return CodeLayer.CORE
157
+ if file_info.is_shell:
158
+ return CodeLayer.SHELL
159
+
160
+ return CodeLayer.DEFAULT
161
+
162
+
163
+ @pre(lambda layer, language="python": isinstance(layer, CodeLayer) and language in ("python", "typescript"))
164
+ @post(lambda result: result.max_file_lines > 0 and result.max_function_lines > 0)
165
+ def get_limits(layer: CodeLayer, language: str = "python") -> LayerLimits:
166
+ """
167
+ Get size limits for layer and language.
168
+
169
+ Examples:
170
+ >>> get_limits(CodeLayer.CORE).max_function_lines
171
+ 50
172
+ >>> get_limits(CodeLayer.SHELL).max_function_lines
173
+ 100
174
+ >>> get_limits(CodeLayer.CORE, "typescript").max_function_lines
175
+ 65
176
+ """
177
+ limits = TYPESCRIPT_LAYER_LIMITS if language == "typescript" else PYTHON_LAYER_LIMITS
178
+ return limits[layer]
179
+
180
+
75
181
  class Violation(BaseModel):
76
182
  """A rule violation found by Guard."""
77
183
 
@@ -27,6 +27,7 @@ class PatternDetector(Protocol):
27
27
  Detectors analyze AST nodes and return suggestions with confidence levels.
28
28
  """
29
29
 
30
+ # @invar:allow missing_doctest: Abstract property - no executable implementation
30
31
  @property
31
32
  @abstractmethod
32
33
  @post(lambda result: result in PatternID)
@@ -34,6 +35,7 @@ class PatternDetector(Protocol):
34
35
  """Unique identifier for this pattern."""
35
36
  ...
36
37
 
38
+ # @invar:allow missing_doctest: Abstract property - no executable implementation
37
39
  @property
38
40
  @abstractmethod
39
41
  @post(lambda result: result in Priority)
@@ -41,6 +43,7 @@ class PatternDetector(Protocol):
41
43
  """Priority tier (P0 or P1)."""
42
44
  ...
43
45
 
46
+ # @invar:allow missing_doctest: Abstract property - no executable implementation
44
47
  @property
45
48
  @abstractmethod
46
49
  @post(lambda result: len(result) > 0)
@@ -48,6 +51,7 @@ class PatternDetector(Protocol):
48
51
  """Human-readable description of the pattern."""
49
52
  ...
50
53
 
54
+ # @invar:allow missing_doctest: Abstract method - no executable implementation
51
55
  @abstractmethod
52
56
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
53
57
  def detect(self, tree: ast.AST, file_path: str) -> list[PatternSuggestion]:
@@ -140,7 +144,8 @@ class BaseDetector:
140
144
  return f"{left} | {right}"
141
145
  else:
142
146
  # Python 3.9+ always has ast.unparse (project requires 3.11+)
143
- return ast.unparse(annotation)
147
+ result = ast.unparse(annotation)
148
+ return result if result else "<unknown>"
144
149
 
145
150
  @pre(lambda self, params, type_name: len(type_name) > 0)
146
151
  @post(lambda result: result >= 0)
@@ -51,19 +51,31 @@ class ExhaustiveMatchDetector(BaseDetector):
51
51
  @property
52
52
  @post(lambda result: result == PatternID.EXHAUSTIVE)
53
53
  def pattern_id(self) -> PatternID:
54
- """Unique identifier for this pattern."""
54
+ """Unique identifier for this pattern.
55
+
56
+ >>> ExhaustiveMatchDetector().pattern_id
57
+ <PatternID.EXHAUSTIVE: 'exhaustive'>
58
+ """
55
59
  return PatternID.EXHAUSTIVE
56
60
 
57
61
  @property
58
62
  @post(lambda result: result == Priority.P0)
59
63
  def priority(self) -> Priority:
60
- """Priority tier."""
64
+ """Priority tier.
65
+
66
+ >>> ExhaustiveMatchDetector().priority
67
+ <Priority.P0: 'P0'>
68
+ """
61
69
  return Priority.P0
62
70
 
63
71
  @property
64
72
  @post(lambda result: len(result) > 0)
65
73
  def description(self) -> str:
66
- """Human-readable description."""
74
+ """Human-readable description.
75
+
76
+ >>> len(ExhaustiveMatchDetector().description) > 0
77
+ True
78
+ """
67
79
  return "Use assert_never for exhaustive enum matching"
68
80
 
69
81
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
@@ -48,19 +48,31 @@ class LiteralDetector(BaseDetector):
48
48
  @property
49
49
  @post(lambda result: result == PatternID.LITERAL)
50
50
  def pattern_id(self) -> PatternID:
51
- """Unique identifier for this pattern."""
51
+ """Unique identifier for this pattern.
52
+
53
+ >>> LiteralDetector().pattern_id
54
+ <PatternID.LITERAL: 'literal'>
55
+ """
52
56
  return PatternID.LITERAL
53
57
 
54
58
  @property
55
59
  @post(lambda result: result == Priority.P0)
56
60
  def priority(self) -> Priority:
57
- """Priority tier."""
61
+ """Priority tier.
62
+
63
+ >>> LiteralDetector().priority
64
+ <Priority.P0: 'P0'>
65
+ """
58
66
  return Priority.P0
59
67
 
60
68
  @property
61
69
  @post(lambda result: len(result) > 0)
62
70
  def description(self) -> str:
63
- """Human-readable description."""
71
+ """Human-readable description.
72
+
73
+ >>> len(LiteralDetector().description) > 0
74
+ True
75
+ """
64
76
  return "Use Literal type for finite value sets"
65
77
 
66
78
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
@@ -50,19 +50,31 @@ class NewTypeDetector(BaseDetector):
50
50
  @property
51
51
  @post(lambda result: result == PatternID.NEWTYPE)
52
52
  def pattern_id(self) -> PatternID:
53
- """Unique identifier for this pattern."""
53
+ """Unique identifier for this pattern.
54
+
55
+ >>> NewTypeDetector().pattern_id
56
+ <PatternID.NEWTYPE: 'newtype'>
57
+ """
54
58
  return PatternID.NEWTYPE
55
59
 
56
60
  @property
57
61
  @post(lambda result: result == Priority.P0)
58
62
  def priority(self) -> Priority:
59
- """Priority tier."""
63
+ """Priority tier.
64
+
65
+ >>> NewTypeDetector().priority
66
+ <Priority.P0: 'P0'>
67
+ """
60
68
  return Priority.P0
61
69
 
62
70
  @property
63
71
  @post(lambda result: len(result) > 0)
64
72
  def description(self) -> str:
65
- """Human-readable description."""
73
+ """Human-readable description.
74
+
75
+ >>> len(NewTypeDetector().description) > 0
76
+ True
77
+ """
66
78
  return "Use NewType for semantic clarity with multiple same-type parameters"
67
79
 
68
80
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
@@ -47,19 +47,31 @@ class NonEmptyDetector(BaseDetector):
47
47
  @property
48
48
  @post(lambda result: result == PatternID.NONEMPTY)
49
49
  def pattern_id(self) -> PatternID:
50
- """Unique identifier for this pattern."""
50
+ """Unique identifier for this pattern.
51
+
52
+ >>> NonEmptyDetector().pattern_id
53
+ <PatternID.NONEMPTY: 'nonempty'>
54
+ """
51
55
  return PatternID.NONEMPTY
52
56
 
53
57
  @property
54
58
  @post(lambda result: result == Priority.P0)
55
59
  def priority(self) -> Priority:
56
- """Priority tier."""
60
+ """Priority tier.
61
+
62
+ >>> NonEmptyDetector().priority
63
+ <Priority.P0: 'P0'>
64
+ """
57
65
  return Priority.P0
58
66
 
59
67
  @property
60
68
  @post(lambda result: len(result) > 0)
61
69
  def description(self) -> str:
62
- """Human-readable description."""
70
+ """Human-readable description.
71
+
72
+ >>> len(NonEmptyDetector().description) > 0
73
+ True
74
+ """
63
75
  return "Use NonEmpty type for compile-time non-empty guarantees"
64
76
 
65
77
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
@@ -55,19 +55,31 @@ class ValidationDetector(BaseDetector):
55
55
  @property
56
56
  @post(lambda result: result == PatternID.VALIDATION)
57
57
  def pattern_id(self) -> PatternID:
58
- """Unique identifier for this pattern."""
58
+ """Unique identifier for this pattern.
59
+
60
+ >>> ValidationDetector().pattern_id
61
+ <PatternID.VALIDATION: 'validation'>
62
+ """
59
63
  return PatternID.VALIDATION
60
64
 
61
65
  @property
62
66
  @post(lambda result: result == Priority.P0)
63
67
  def priority(self) -> Priority:
64
- """Priority tier."""
68
+ """Priority tier.
69
+
70
+ >>> ValidationDetector().priority
71
+ <Priority.P0: 'P0'>
72
+ """
65
73
  return Priority.P0
66
74
 
67
75
  @property
68
76
  @post(lambda result: len(result) > 0)
69
77
  def description(self) -> str:
70
- """Human-readable description."""
78
+ """Human-readable description.
79
+
80
+ >>> len(ValidationDetector().description) > 0
81
+ True
82
+ """
71
83
  return "Use error accumulation instead of fail-fast validation"
72
84
 
73
85
  @post(lambda result: all(isinstance(s, PatternSuggestion) for s in result))
@@ -53,7 +53,11 @@ class PatternRegistry:
53
53
  @property
54
54
  @post(lambda result: len(result) > 0)
55
55
  def detectors(self) -> list[PatternDetector]:
56
- """Get all registered detectors."""
56
+ """Get all registered detectors.
57
+
58
+ >>> len(PatternRegistry().detectors) >= 5
59
+ True
60
+ """
57
61
  return self._detectors
58
62
 
59
63
  @post(lambda result: result is not None)
@@ -149,7 +149,11 @@ class DetectionResult:
149
149
  @property
150
150
  @post(lambda result: isinstance(result, bool))
151
151
  def has_suggestions(self) -> bool:
152
- """Check if any suggestions were found."""
152
+ """Check if any suggestions were found.
153
+
154
+ >>> DetectionResult(file="test.py", suggestions=[], patterns_checked=[]).has_suggestions
155
+ False
156
+ """
153
157
  return len(self.suggestions) > 0
154
158
 
155
159
  @pre(lambda self, min_confidence: min_confidence in Confidence)
@@ -368,6 +368,7 @@ def _skip_result(name: str, reason: str) -> PropertyTestResult:
368
368
  _SKIP_PATTERNS = (
369
369
  "Nothing", "NoSuchExample", "filter_too_much", "Could not resolve",
370
370
  "validation error", "missing", "positional argument", "Unable to satisfy",
371
+ "has no attribute 'check'", # invar_runtime contracts, not deal contracts
371
372
  )
372
373
 
373
374
 
@@ -377,6 +378,9 @@ def _handle_test_exception(
377
378
  err_str: str, func_name: str, max_examples: int
378
379
  ) -> PropertyTestResult:
379
380
  """Handle exception from property test, returning skip or failure result."""
381
+ # Check for invar_runtime contracts (deal.cases requires deal contracts)
382
+ if "has no attribute 'check'" in err_str:
383
+ return _skip_result(func_name, "Skipped: uses invar_runtime (deal.cases requires deal contracts)")
380
384
  if any(p in err_str for p in _SKIP_PATTERNS):
381
385
  return _skip_result(func_name, "Skipped: untestable types")
382
386
  seed = _extract_hypothesis_seed(err_str)