invar-tools 1.8.0__py3-none-any.whl → 1.11.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 +8 -0
- invar/core/doc_edit.py +187 -0
- invar/core/doc_parser.py +563 -0
- invar/core/language.py +88 -0
- invar/core/models.py +106 -0
- invar/core/patterns/detector.py +6 -1
- invar/core/patterns/p0_exhaustive.py +15 -3
- invar/core/patterns/p0_literal.py +15 -3
- invar/core/patterns/p0_newtype.py +15 -3
- invar/core/patterns/p0_nonempty.py +15 -3
- invar/core/patterns/p0_validation.py +15 -3
- invar/core/patterns/registry.py +5 -1
- invar/core/patterns/types.py +5 -1
- invar/core/property_gen.py +4 -0
- invar/core/rules.py +84 -18
- invar/core/sync_helpers.py +27 -1
- invar/core/ts_parsers.py +286 -0
- invar/core/ts_sig_parser.py +310 -0
- invar/mcp/handlers.py +408 -0
- invar/mcp/server.py +288 -143
- invar/node_tools/MANIFEST +7 -0
- invar/node_tools/__init__.py +51 -0
- invar/node_tools/fc-runner/cli.js +77 -0
- invar/node_tools/quick-check/cli.js +28 -0
- invar/node_tools/ts-analyzer/cli.js +480 -0
- invar/shell/claude_hooks.py +35 -12
- invar/shell/commands/doc.py +409 -0
- invar/shell/commands/guard.py +41 -1
- invar/shell/commands/init.py +154 -16
- invar/shell/commands/perception.py +157 -33
- invar/shell/commands/skill.py +187 -0
- invar/shell/commands/template_sync.py +65 -13
- invar/shell/commands/uninstall.py +60 -12
- invar/shell/commands/update.py +6 -14
- invar/shell/contract_coverage.py +1 -0
- invar/shell/doc_tools.py +459 -0
- invar/shell/fs.py +67 -13
- invar/shell/pi_hooks.py +6 -0
- invar/shell/prove/crosshair.py +3 -0
- invar/shell/prove/guard_ts.py +902 -0
- invar/shell/skill_manager.py +355 -0
- invar/shell/template_engine.py +28 -4
- invar/shell/templates.py +4 -4
- invar/templates/claude-md/python/critical-rules.md +33 -0
- invar/templates/claude-md/python/quick-reference.md +24 -0
- invar/templates/claude-md/typescript/critical-rules.md +40 -0
- invar/templates/claude-md/typescript/quick-reference.md +24 -0
- invar/templates/claude-md/universal/check-in.md +25 -0
- invar/templates/claude-md/universal/skills.md +73 -0
- invar/templates/claude-md/universal/workflow.md +55 -0
- invar/templates/commands/{audit.md → audit.md.jinja} +18 -1
- invar/templates/config/AGENT.md.jinja +58 -0
- invar/templates/config/CLAUDE.md.jinja +16 -209
- invar/templates/config/context.md.jinja +19 -0
- invar/templates/examples/{README.md → python/README.md} +2 -0
- invar/templates/examples/{conftest.py → python/conftest.py} +1 -1
- invar/templates/examples/{contracts.py → python/contracts.py} +81 -4
- invar/templates/examples/python/core_shell.py +227 -0
- invar/templates/examples/python/functional.py +613 -0
- invar/templates/examples/typescript/README.md +31 -0
- invar/templates/examples/typescript/contracts.ts +163 -0
- invar/templates/examples/typescript/core_shell.ts +374 -0
- invar/templates/examples/typescript/functional.ts +601 -0
- invar/templates/examples/typescript/workflow.md +95 -0
- invar/templates/hooks/PostToolUse.sh.jinja +10 -1
- invar/templates/hooks/PreToolUse.sh.jinja +38 -0
- invar/templates/hooks/Stop.sh.jinja +1 -1
- invar/templates/hooks/UserPromptSubmit.sh.jinja +7 -0
- invar/templates/hooks/pi/invar.ts.jinja +9 -0
- invar/templates/manifest.toml +7 -6
- invar/templates/onboard/assessment.md.jinja +214 -0
- invar/templates/onboard/patterns/python.md +347 -0
- invar/templates/onboard/patterns/typescript.md +452 -0
- invar/templates/onboard/roadmap.md.jinja +168 -0
- invar/templates/protocol/INVAR.md.jinja +51 -0
- invar/templates/protocol/python/architecture-examples.md +41 -0
- invar/templates/protocol/python/contracts-syntax.md +56 -0
- invar/templates/protocol/python/markers.md +44 -0
- invar/templates/protocol/python/tools.md +24 -0
- invar/templates/protocol/python/troubleshooting.md +38 -0
- invar/templates/protocol/typescript/architecture-examples.md +52 -0
- invar/templates/protocol/typescript/contracts-syntax.md +73 -0
- invar/templates/protocol/typescript/markers.md +48 -0
- invar/templates/protocol/typescript/tools.md +65 -0
- invar/templates/protocol/typescript/troubleshooting.md +104 -0
- invar/templates/protocol/universal/architecture.md +36 -0
- invar/templates/protocol/universal/completion.md +14 -0
- invar/templates/protocol/universal/contracts-concept.md +37 -0
- invar/templates/protocol/universal/header.md +17 -0
- invar/templates/protocol/universal/session.md +17 -0
- invar/templates/protocol/universal/six-laws.md +10 -0
- invar/templates/protocol/universal/usbv.md +14 -0
- invar/templates/protocol/universal/visible-workflow.md +25 -0
- invar/templates/skills/develop/SKILL.md.jinja +85 -3
- invar/templates/skills/extensions/_registry.yaml +93 -0
- invar/templates/skills/extensions/acceptance/SKILL.md +383 -0
- invar/templates/skills/extensions/invar-onboard/SKILL.md +448 -0
- invar/templates/skills/extensions/invar-onboard/patterns/python.md +347 -0
- invar/templates/skills/extensions/invar-onboard/patterns/typescript.md +452 -0
- invar/templates/skills/extensions/invar-onboard/templates/assessment.md.jinja +214 -0
- invar/templates/skills/extensions/invar-onboard/templates/roadmap.md.jinja +168 -0
- invar/templates/skills/extensions/security/SKILL.md +382 -0
- invar/templates/skills/extensions/security/patterns/_common.yaml +126 -0
- invar/templates/skills/extensions/security/patterns/python.yaml +155 -0
- invar/templates/skills/extensions/security/patterns/typescript.yaml +194 -0
- invar/templates/skills/review/SKILL.md.jinja +220 -248
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/METADATA +336 -12
- invar_tools-1.11.0.dist-info/RECORD +178 -0
- invar/templates/examples/core_shell.py +0 -127
- invar/templates/protocol/INVAR.md +0 -310
- invar_tools-1.8.0.dist-info/RECORD +0 -116
- /invar/templates/examples/{workflow.md → python/workflow.md} +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.11.0.dist-info}/licenses/NOTICE +0 -0
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
|
|
invar/core/patterns/detector.py
CHANGED
|
@@ -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
|
-
|
|
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))
|
invar/core/patterns/registry.py
CHANGED
|
@@ -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)
|
invar/core/patterns/types.py
CHANGED
|
@@ -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)
|
invar/core/property_gen.py
CHANGED
|
@@ -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)
|
invar/core/rules.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
# @invar:allow file_size: LX-10 added doctests, consider extraction later
|
|
1
2
|
"""Rule engine for Guard. Rules check FileInfo and produce Violations. No I/O."""
|
|
2
3
|
|
|
3
4
|
from __future__ import annotations
|
|
4
5
|
|
|
5
6
|
from collections.abc import Callable
|
|
6
7
|
|
|
7
|
-
from deal import post
|
|
8
|
+
from deal import post, pre
|
|
8
9
|
|
|
9
10
|
from invar.core.contracts import (
|
|
10
11
|
check_empty_contracts,
|
|
@@ -14,9 +15,22 @@ from invar.core.contracts import (
|
|
|
14
15
|
check_semantic_tautology,
|
|
15
16
|
check_skip_without_reason,
|
|
16
17
|
)
|
|
17
|
-
from invar.core.entry_points import
|
|
18
|
+
from invar.core.entry_points import (
|
|
19
|
+
extract_escape_hatches,
|
|
20
|
+
get_symbol_lines,
|
|
21
|
+
has_allow_marker,
|
|
22
|
+
is_entry_point,
|
|
23
|
+
)
|
|
18
24
|
from invar.core.extraction import format_extraction_hint
|
|
19
|
-
from invar.core.models import
|
|
25
|
+
from invar.core.models import (
|
|
26
|
+
FileInfo,
|
|
27
|
+
RuleConfig,
|
|
28
|
+
Severity,
|
|
29
|
+
SymbolKind,
|
|
30
|
+
Violation,
|
|
31
|
+
get_layer,
|
|
32
|
+
get_limits,
|
|
33
|
+
)
|
|
20
34
|
from invar.core.must_use import check_must_use
|
|
21
35
|
from invar.core.postcondition_scope import check_postcondition_scope
|
|
22
36
|
from invar.core.purity import check_impure_calls, check_internal_imports
|
|
@@ -63,11 +77,33 @@ def _get_func_hint(file_info: FileInfo) -> str:
|
|
|
63
77
|
return f" Functions: {', '.join(f'{n}({sz}L)' for n, sz in funcs)}" if funcs else ""
|
|
64
78
|
|
|
65
79
|
|
|
80
|
+
@pre(lambda file_info, rule: file_info is not None and len(rule) > 0)
|
|
81
|
+
@post(lambda result: isinstance(result, bool))
|
|
82
|
+
def _has_file_escape(file_info: FileInfo, rule: str) -> bool:
|
|
83
|
+
"""Check if file has escape hatch for given rule.
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
>>> info = FileInfo(path="test.py", lines=10, source="# @invar:allow file_size: reason")
|
|
87
|
+
>>> _has_file_escape(info, "file_size")
|
|
88
|
+
True
|
|
89
|
+
>>> _has_file_escape(info, "other_rule")
|
|
90
|
+
False
|
|
91
|
+
>>> # Edge: empty source returns False
|
|
92
|
+
>>> _has_file_escape(FileInfo(path="x.py", lines=1), "any")
|
|
93
|
+
False
|
|
94
|
+
"""
|
|
95
|
+
if not file_info.source:
|
|
96
|
+
return False
|
|
97
|
+
escapes = extract_escape_hatches(file_info.source)
|
|
98
|
+
return any(r == rule for r, _, _ in escapes)
|
|
99
|
+
|
|
100
|
+
|
|
66
101
|
@post(lambda result: all(v.rule in ("file_size", "file_size_warning") for v in result))
|
|
67
102
|
def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
68
103
|
"""
|
|
69
104
|
Check if file exceeds maximum line count or warning threshold.
|
|
70
105
|
|
|
106
|
+
LX-10: Uses layer-based limits (Core/Shell/Tests/Default).
|
|
71
107
|
P18: Shows function groups in size warnings to help agents decide what to extract.
|
|
72
108
|
P25: Shows extractable groups with dependencies for warnings.
|
|
73
109
|
|
|
@@ -75,30 +111,43 @@ def check_file_size(file_info: FileInfo, config: RuleConfig) -> list[Violation]:
|
|
|
75
111
|
>>> from invar.core.models import FileInfo, RuleConfig
|
|
76
112
|
>>> check_file_size(FileInfo(path="ok.py", lines=100), RuleConfig())
|
|
77
113
|
[]
|
|
78
|
-
>>>
|
|
114
|
+
>>> # Default layer: 600 lines max, error at 650
|
|
115
|
+
>>> len(check_file_size(FileInfo(path="big.py", lines=650), RuleConfig()))
|
|
116
|
+
1
|
|
117
|
+
>>> # Shell layer: 700 lines max, no error at 650
|
|
118
|
+
>>> vs = check_file_size(FileInfo(path="shell/cli.py", lines=550, is_shell=True), RuleConfig())
|
|
119
|
+
>>> any(v.rule == "file_size" for v in vs)
|
|
120
|
+
False
|
|
121
|
+
>>> # Core layer: 500 lines max (strict)
|
|
122
|
+
>>> len(check_file_size(FileInfo(path="core/calc.py", lines=550, is_core=True), RuleConfig()))
|
|
79
123
|
1
|
|
80
|
-
>>> # P8: Warning at 80% threshold (400 lines when max is 500)
|
|
81
|
-
>>> vs = check_file_size(FileInfo(path="growing.py", lines=420), RuleConfig())
|
|
82
|
-
>>> len(vs) == 1 and vs[0].rule == "file_size_warning"
|
|
83
|
-
True
|
|
84
124
|
"""
|
|
125
|
+
# Check for escape hatch
|
|
126
|
+
if _has_file_escape(file_info, "file_size"):
|
|
127
|
+
return []
|
|
128
|
+
|
|
85
129
|
violations: list[Violation] = []
|
|
86
130
|
func_hint = _get_func_hint(file_info)
|
|
87
131
|
extraction_hint = format_extraction_hint(file_info)
|
|
88
132
|
|
|
89
|
-
|
|
133
|
+
# LX-10: Get layer-based limits
|
|
134
|
+
layer = get_layer(file_info)
|
|
135
|
+
limits = get_limits(layer)
|
|
136
|
+
max_lines = limits.max_file_lines
|
|
137
|
+
|
|
138
|
+
if file_info.lines > max_lines:
|
|
90
139
|
violations.append(Violation(
|
|
91
140
|
rule="file_size", severity=Severity.ERROR, file=file_info.path, line=None,
|
|
92
|
-
message=f"File has {file_info.lines} lines (max: {
|
|
141
|
+
message=f"File has {file_info.lines} lines (max: {max_lines} for {layer.value})",
|
|
93
142
|
suggestion=_build_size_suggestion("Split into smaller modules.", extraction_hint, func_hint),
|
|
94
143
|
))
|
|
95
144
|
elif config.size_warning_threshold > 0:
|
|
96
|
-
threshold = int(
|
|
145
|
+
threshold = int(max_lines * config.size_warning_threshold)
|
|
97
146
|
if file_info.lines >= threshold:
|
|
98
|
-
pct = int(file_info.lines /
|
|
147
|
+
pct = int(file_info.lines / max_lines * 100)
|
|
99
148
|
violations.append(Violation(
|
|
100
149
|
rule="file_size_warning", severity=Severity.WARNING, file=file_info.path, line=None,
|
|
101
|
-
message=f"File has {file_info.lines} lines ({pct}% of {
|
|
150
|
+
message=f"File has {file_info.lines} lines ({pct}% of {max_lines} limit)",
|
|
102
151
|
suggestion=_build_size_suggestion("Consider splitting before reaching limit.", extraction_hint, func_hint),
|
|
103
152
|
))
|
|
104
153
|
return violations
|
|
@@ -109,21 +158,38 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
|
|
|
109
158
|
"""
|
|
110
159
|
Check if any function exceeds maximum line count.
|
|
111
160
|
|
|
161
|
+
LX-10: Uses layer-based limits (Core/Shell/Tests/Default).
|
|
112
162
|
DX-22: Always uses code_lines (excluding docstring) and excludes doctest lines.
|
|
113
|
-
These behaviors were previously optional but are now the default.
|
|
114
163
|
|
|
115
164
|
Examples:
|
|
116
165
|
>>> from invar.core.models import FileInfo, Symbol, SymbolKind
|
|
117
166
|
>>> sym = Symbol(name="foo", kind=SymbolKind.FUNCTION, line=1, end_line=10)
|
|
118
167
|
>>> info = FileInfo(path="test.py", lines=20, symbols=[sym])
|
|
119
|
-
>>>
|
|
120
|
-
|
|
168
|
+
>>> check_function_size(info, RuleConfig())
|
|
169
|
+
[]
|
|
170
|
+
>>> # Shell layer: 100 lines max (more lenient)
|
|
171
|
+
>>> sym2 = Symbol(name="cli", kind=SymbolKind.FUNCTION, line=1, end_line=80)
|
|
172
|
+
>>> info2 = FileInfo(path="shell/cli.py", lines=100, symbols=[sym2], is_shell=True)
|
|
173
|
+
>>> check_function_size(info2, RuleConfig())
|
|
121
174
|
[]
|
|
175
|
+
>>> # Core layer: 50 lines max (strict)
|
|
176
|
+
>>> sym3 = Symbol(name="calc", kind=SymbolKind.FUNCTION, line=1, end_line=60)
|
|
177
|
+
>>> info3 = FileInfo(path="core/calc.py", lines=100, symbols=[sym3], is_core=True)
|
|
178
|
+
>>> len(check_function_size(info3, RuleConfig()))
|
|
179
|
+
1
|
|
122
180
|
"""
|
|
123
181
|
violations: list[Violation] = []
|
|
124
182
|
|
|
183
|
+
# LX-10: Get layer-based limits
|
|
184
|
+
layer = get_layer(file_info)
|
|
185
|
+
limits = get_limits(layer)
|
|
186
|
+
max_func_lines = limits.max_function_lines
|
|
187
|
+
|
|
125
188
|
for symbol in file_info.symbols:
|
|
126
189
|
if symbol.kind in (SymbolKind.FUNCTION, SymbolKind.METHOD):
|
|
190
|
+
# LX-10: Check for escape hatch on individual functions
|
|
191
|
+
if has_allow_marker(symbol, file_info.source, "function_size"):
|
|
192
|
+
continue
|
|
127
193
|
total_lines = symbol.end_line - symbol.line + 1
|
|
128
194
|
# DX-22: Always use code_lines when available (excluding docstring)
|
|
129
195
|
if symbol.code_lines is not None:
|
|
@@ -137,14 +203,14 @@ def check_function_size(file_info: FileInfo, config: RuleConfig) -> list[Violati
|
|
|
137
203
|
func_lines -= symbol.doctest_lines
|
|
138
204
|
line_type = f"{line_type} (excl. doctest)"
|
|
139
205
|
|
|
140
|
-
if func_lines >
|
|
206
|
+
if func_lines > max_func_lines:
|
|
141
207
|
violations.append(
|
|
142
208
|
Violation(
|
|
143
209
|
rule="function_size",
|
|
144
210
|
severity=Severity.WARNING,
|
|
145
211
|
file=file_info.path,
|
|
146
212
|
line=symbol.line,
|
|
147
|
-
message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {
|
|
213
|
+
message=f"Function '{symbol.name}' has {func_lines} {line_type} (max: {max_func_lines} for {layer.value})",
|
|
148
214
|
suggestion="Extract helper functions",
|
|
149
215
|
)
|
|
150
216
|
)
|
invar/core/sync_helpers.py
CHANGED
|
@@ -21,6 +21,10 @@ if TYPE_CHECKING:
|
|
|
21
21
|
# =============================================================================
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
# LX-05: Valid language values for template rendering
|
|
25
|
+
VALID_LANGUAGES = frozenset({"python", "typescript"})
|
|
26
|
+
|
|
27
|
+
|
|
24
28
|
@dataclass
|
|
25
29
|
class SyncConfig:
|
|
26
30
|
"""Configuration for template sync operation.
|
|
@@ -29,12 +33,16 @@ class SyncConfig:
|
|
|
29
33
|
>>> config = SyncConfig()
|
|
30
34
|
>>> config.syntax
|
|
31
35
|
'cli'
|
|
36
|
+
>>> config.language
|
|
37
|
+
'python'
|
|
32
38
|
>>> config.inject_project_additions
|
|
33
39
|
False
|
|
34
40
|
|
|
35
|
-
>>> config = SyncConfig(syntax="mcp", force=True)
|
|
41
|
+
>>> config = SyncConfig(syntax="mcp", language="typescript", force=True)
|
|
36
42
|
>>> config.syntax
|
|
37
43
|
'mcp'
|
|
44
|
+
>>> config.language
|
|
45
|
+
'typescript'
|
|
38
46
|
>>> config.force
|
|
39
47
|
True
|
|
40
48
|
|
|
@@ -44,12 +52,30 @@ class SyncConfig:
|
|
|
44
52
|
"""
|
|
45
53
|
|
|
46
54
|
syntax: str = "cli" # "cli" or "mcp"
|
|
55
|
+
language: str = "python" # "python" or "typescript" (LX-05)
|
|
47
56
|
inject_project_additions: bool = False
|
|
48
57
|
force: bool = False
|
|
49
58
|
check: bool = False # Preview only
|
|
50
59
|
reset: bool = False # Discard user content
|
|
51
60
|
skip_patterns: list[str] = field(default_factory=list) # Glob patterns to skip
|
|
52
61
|
|
|
62
|
+
@post(lambda result: result is None) # Void method, validates or raises
|
|
63
|
+
def __post_init__(self) -> None:
|
|
64
|
+
"""Validate configuration values.
|
|
65
|
+
|
|
66
|
+
Examples:
|
|
67
|
+
>>> SyncConfig(language="python") # Valid
|
|
68
|
+
SyncConfig(syntax='cli', language='python', inject_project_additions=False, force=False, check=False, reset=False, skip_patterns=[])
|
|
69
|
+
|
|
70
|
+
>>> SyncConfig(language="rust") # doctest: +IGNORE_EXCEPTION_DETAIL
|
|
71
|
+
Traceback (most recent call last):
|
|
72
|
+
ValueError: Invalid language 'rust'. Must be one of: python, typescript
|
|
73
|
+
"""
|
|
74
|
+
if self.language not in VALID_LANGUAGES:
|
|
75
|
+
valid = ", ".join(sorted(VALID_LANGUAGES))
|
|
76
|
+
msg = f"Invalid language '{self.language}'. Must be one of: {valid}"
|
|
77
|
+
raise ValueError(msg)
|
|
78
|
+
|
|
53
79
|
|
|
54
80
|
@dataclass
|
|
55
81
|
class SyncReport:
|