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.
- invar/__init__.py +8 -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 +307 -0
- 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/guard.py +36 -1
- invar/shell/commands/init.py +82 -3
- 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/fs.py +66 -13
- invar/shell/pi_hooks.py +6 -0
- invar/shell/prove/guard_ts.py +899 -0
- invar/shell/skill_manager.py +353 -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 +39 -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 +331 -71
- {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/METADATA +304 -12
- invar_tools-1.10.0.dist-info/RECORD +173 -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.10.0.dist-info}/WHEEL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/entry_points.txt +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/licenses/LICENSE-GPL +0 -0
- {invar_tools-1.8.0.dist-info → invar_tools-1.10.0.dist-info}/licenses/NOTICE +0 -0
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Invar Functional Pattern Examples (DX-61)
|
|
3
|
+
|
|
4
|
+
Reference patterns for higher-quality code. These are SUGGESTIONS, not requirements.
|
|
5
|
+
Guard will suggest them when it detects opportunities for improvement.
|
|
6
|
+
|
|
7
|
+
Patterns covered:
|
|
8
|
+
P0 (Core):
|
|
9
|
+
1. NewType - Semantic clarity for primitive types
|
|
10
|
+
2. Validation - Error accumulation instead of fail-fast
|
|
11
|
+
3. NonEmpty - Compile-time safety for non-empty collections
|
|
12
|
+
4. Literal - Type-safe finite value sets
|
|
13
|
+
5. ExhaustiveMatch - Catch missing cases at compile time
|
|
14
|
+
|
|
15
|
+
P1 (Extended):
|
|
16
|
+
6. SmartConstructor - Validation at construction time
|
|
17
|
+
7. StructuredError - Typed errors for programmatic handling
|
|
18
|
+
|
|
19
|
+
Managed by Invar - do not edit directly.
|
|
20
|
+
"""
|
|
21
|
+
# @invar:allow missing_contract: Educational file with intentional "bad" examples
|
|
22
|
+
# @invar:allow partial_contract: Educational file with intentional "bad" examples
|
|
23
|
+
# @invar:allow contract_quality_ratio: Educational file - coverage intentionally low
|
|
24
|
+
# @invar:allow file_size: Educational file with comprehensive pattern examples
|
|
25
|
+
# @invar:allow internal_import: Demo functions show self-contained examples
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from enum import Enum
|
|
30
|
+
from typing import Generic, Literal, NewType, TypeVar, assert_never
|
|
31
|
+
|
|
32
|
+
from invar_runtime import post, pre
|
|
33
|
+
from returns.result import Failure, Result, Success
|
|
34
|
+
|
|
35
|
+
T = TypeVar("T")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# =============================================================================
|
|
39
|
+
# Pattern 1: NewType for Semantic Clarity (P0)
|
|
40
|
+
# =============================================================================
|
|
41
|
+
|
|
42
|
+
# BEFORE: Easy to confuse parameters - all are just "str"
|
|
43
|
+
# def find_symbol_bad(
|
|
44
|
+
# module_path: str,
|
|
45
|
+
# symbol_name: str,
|
|
46
|
+
# file_pattern: str,
|
|
47
|
+
# ) -> Symbol:
|
|
48
|
+
# ...
|
|
49
|
+
|
|
50
|
+
# AFTER: Self-documenting, type-checker catches mistakes
|
|
51
|
+
ModulePath = NewType("ModulePath", str)
|
|
52
|
+
SymbolName = NewType("SymbolName", str)
|
|
53
|
+
FilePattern = NewType("FilePattern", str)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Symbol:
|
|
58
|
+
"""Example symbol for demonstration."""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
line: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pre(lambda path, name: len(path) > 0 and len(name) > 0)
|
|
65
|
+
@post(lambda result: result is not None)
|
|
66
|
+
def find_symbol(path: ModulePath, name: SymbolName) -> Symbol:
|
|
67
|
+
"""
|
|
68
|
+
Find symbol by module path and name.
|
|
69
|
+
|
|
70
|
+
With NewType, swapping arguments is a type error:
|
|
71
|
+
find_symbol(name, path) # Type checker catches this!
|
|
72
|
+
|
|
73
|
+
>>> find_symbol(ModulePath("src/core"), SymbolName("calculate"))
|
|
74
|
+
Symbol(name='calculate', line=42)
|
|
75
|
+
"""
|
|
76
|
+
# Demo implementation
|
|
77
|
+
return Symbol(name=name, line=42)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# =============================================================================
|
|
81
|
+
# Pattern 2: Validation for Error Accumulation (P0)
|
|
82
|
+
# =============================================================================
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class Config:
|
|
87
|
+
"""Example config for validation demo."""
|
|
88
|
+
|
|
89
|
+
path: str
|
|
90
|
+
max_lines: int
|
|
91
|
+
enabled: bool
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# BEFORE: User sees one error at a time
|
|
95
|
+
def validate_config_bad(data: dict) -> Result[Config, str]:
|
|
96
|
+
"""
|
|
97
|
+
Bad: Fail-fast validation.
|
|
98
|
+
|
|
99
|
+
If multiple fields are invalid, user only sees the first error.
|
|
100
|
+
They fix it, run again, see the next error. Frustrating!
|
|
101
|
+
"""
|
|
102
|
+
if "path" not in data:
|
|
103
|
+
return Failure("Missing 'path'")
|
|
104
|
+
if "max_lines" not in data:
|
|
105
|
+
return Failure("Missing 'max_lines'") # Never reached if path missing
|
|
106
|
+
if "enabled" not in data:
|
|
107
|
+
return Failure("Missing 'enabled'")
|
|
108
|
+
return Success(Config(**data))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# AFTER: User sees all errors at once
|
|
112
|
+
def validate_config_good(data: dict) -> Result[Config, list[str]]:
|
|
113
|
+
"""
|
|
114
|
+
Good: Accumulating validation.
|
|
115
|
+
|
|
116
|
+
Collect all errors, return them together. User can fix everything
|
|
117
|
+
in one iteration. Much better UX!
|
|
118
|
+
|
|
119
|
+
>>> validate_config_good({})
|
|
120
|
+
Failure(["Missing 'path'", "Missing 'max_lines'", "Missing 'enabled'"])
|
|
121
|
+
|
|
122
|
+
>>> validate_config_good({"path": "/tmp", "max_lines": 100, "enabled": True})
|
|
123
|
+
Success(Config(path='/tmp', max_lines=100, enabled=True))
|
|
124
|
+
|
|
125
|
+
>>> result = validate_config_good({"path": ""})
|
|
126
|
+
>>> isinstance(result, Failure)
|
|
127
|
+
True
|
|
128
|
+
>>> "path cannot be empty" in result.failure()
|
|
129
|
+
True
|
|
130
|
+
"""
|
|
131
|
+
errors: list[str] = []
|
|
132
|
+
|
|
133
|
+
# Collect ALL errors, don't return early
|
|
134
|
+
if "path" not in data:
|
|
135
|
+
errors.append("Missing 'path'")
|
|
136
|
+
elif not data["path"]:
|
|
137
|
+
errors.append("path cannot be empty")
|
|
138
|
+
|
|
139
|
+
if "max_lines" not in data:
|
|
140
|
+
errors.append("Missing 'max_lines'")
|
|
141
|
+
elif not isinstance(data["max_lines"], int):
|
|
142
|
+
errors.append("max_lines must be an integer")
|
|
143
|
+
elif data["max_lines"] < 0:
|
|
144
|
+
errors.append("max_lines must be >= 0")
|
|
145
|
+
|
|
146
|
+
if "enabled" not in data:
|
|
147
|
+
errors.append("Missing 'enabled'")
|
|
148
|
+
|
|
149
|
+
if errors:
|
|
150
|
+
return Failure(errors)
|
|
151
|
+
|
|
152
|
+
return Success(
|
|
153
|
+
Config(
|
|
154
|
+
path=data["path"],
|
|
155
|
+
max_lines=data["max_lines"],
|
|
156
|
+
enabled=data["enabled"],
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# =============================================================================
|
|
162
|
+
# Pattern 3: NonEmpty for Compile-Time Safety (P0)
|
|
163
|
+
# =============================================================================
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class NonEmpty(Generic[T]):
|
|
168
|
+
"""
|
|
169
|
+
List guaranteed to have at least one element.
|
|
170
|
+
|
|
171
|
+
Instead of runtime checks like `if not items: raise`,
|
|
172
|
+
use the type system to guarantee non-emptiness.
|
|
173
|
+
|
|
174
|
+
>>> ne = NonEmpty.from_list([1, 2, 3])
|
|
175
|
+
>>> isinstance(ne, Success)
|
|
176
|
+
True
|
|
177
|
+
>>> ne.unwrap().first
|
|
178
|
+
1
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
head: T
|
|
182
|
+
tail: tuple[T, ...]
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def first(self) -> T:
|
|
186
|
+
"""Always safe - guaranteed non-empty by construction."""
|
|
187
|
+
return self.head
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def all(self) -> tuple[T, ...]:
|
|
191
|
+
"""Get all elements as tuple."""
|
|
192
|
+
return (self.head, *self.tail)
|
|
193
|
+
|
|
194
|
+
def __len__(self) -> int:
|
|
195
|
+
"""Length is always >= 1."""
|
|
196
|
+
return 1 + len(self.tail)
|
|
197
|
+
|
|
198
|
+
@classmethod
|
|
199
|
+
def from_list(cls, items: list[T]) -> Result["NonEmpty[T]", str]:
|
|
200
|
+
"""
|
|
201
|
+
Safely construct from list.
|
|
202
|
+
|
|
203
|
+
>>> NonEmpty.from_list([])
|
|
204
|
+
Failure('Cannot create NonEmpty from empty list')
|
|
205
|
+
|
|
206
|
+
>>> NonEmpty.from_list([1, 2, 3])
|
|
207
|
+
Success(NonEmpty(head=1, tail=(2, 3)))
|
|
208
|
+
"""
|
|
209
|
+
if not items:
|
|
210
|
+
return Failure("Cannot create NonEmpty from empty list")
|
|
211
|
+
return Success(cls(head=items[0], tail=tuple(items[1:])))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# BEFORE: Defensive runtime check
|
|
215
|
+
def summarize_bad(items: list[str]) -> str:
|
|
216
|
+
"""
|
|
217
|
+
Bad: Runtime check that could be compile-time.
|
|
218
|
+
|
|
219
|
+
The `if not items` check is defensive but the type system
|
|
220
|
+
can't help prevent calling with empty list.
|
|
221
|
+
"""
|
|
222
|
+
if not items:
|
|
223
|
+
raise ValueError("Cannot summarize empty list")
|
|
224
|
+
return f"First: {items[0]}, Total: {len(items)}"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# AFTER: Type-safe, no check needed
|
|
228
|
+
@post(lambda result: "First:" in result)
|
|
229
|
+
def summarize_good(items: NonEmpty[str]) -> str:
|
|
230
|
+
"""
|
|
231
|
+
Good: Type guarantees non-empty.
|
|
232
|
+
|
|
233
|
+
No runtime check needed - if you have a NonEmpty,
|
|
234
|
+
it's guaranteed to have at least one element.
|
|
235
|
+
|
|
236
|
+
>>> ne = NonEmpty.from_list(["a", "b", "c"]).unwrap()
|
|
237
|
+
>>> summarize_good(ne)
|
|
238
|
+
'First: a, Total: 3'
|
|
239
|
+
"""
|
|
240
|
+
return f"First: {items.first}, Total: {len(items)}"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# =============================================================================
|
|
244
|
+
# Pattern 4: Literal for Finite Value Sets (P0)
|
|
245
|
+
# =============================================================================
|
|
246
|
+
|
|
247
|
+
# BEFORE: Runtime validation for finite set
|
|
248
|
+
def set_log_level_bad(level: str) -> None:
|
|
249
|
+
"""
|
|
250
|
+
Bad: Runtime check for finite values.
|
|
251
|
+
|
|
252
|
+
Type checker can't know that only certain strings are valid.
|
|
253
|
+
Invalid values discovered at runtime.
|
|
254
|
+
"""
|
|
255
|
+
if level not in ("debug", "info", "warning", "error"):
|
|
256
|
+
raise ValueError(f"Invalid log level: {level}")
|
|
257
|
+
# ... set the level
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# AFTER: Compile-time safety with Literal
|
|
261
|
+
LogLevel = Literal["debug", "info", "warning", "error"]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def set_log_level_good(level: LogLevel) -> str:
|
|
265
|
+
"""
|
|
266
|
+
Good: Type checker catches invalid values.
|
|
267
|
+
|
|
268
|
+
>>> set_log_level_good("debug")
|
|
269
|
+
'Log level set to: debug'
|
|
270
|
+
|
|
271
|
+
# This would be a type error (caught by mypy/pyright):
|
|
272
|
+
# set_log_level_good("invalid") # Error!
|
|
273
|
+
"""
|
|
274
|
+
return f"Log level set to: {level}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# =============================================================================
|
|
278
|
+
# Pattern 5: Exhaustive Match (P0)
|
|
279
|
+
# =============================================================================
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class Status(Enum):
|
|
283
|
+
"""Task status for exhaustive match demo."""
|
|
284
|
+
|
|
285
|
+
PENDING = "pending"
|
|
286
|
+
RUNNING = "running"
|
|
287
|
+
DONE = "done"
|
|
288
|
+
FAILED = "failed"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# BEFORE: Missing cases fail silently
|
|
292
|
+
def status_message_bad(status: Status) -> str:
|
|
293
|
+
"""
|
|
294
|
+
Bad: Non-exhaustive match.
|
|
295
|
+
|
|
296
|
+
If a new status is added (e.g., CANCELLED), this code
|
|
297
|
+
silently returns "unknown" instead of failing to compile.
|
|
298
|
+
"""
|
|
299
|
+
match status:
|
|
300
|
+
case Status.PENDING:
|
|
301
|
+
return "Waiting to start"
|
|
302
|
+
case Status.RUNNING:
|
|
303
|
+
return "In progress"
|
|
304
|
+
# DONE and FAILED missing - falls through to default!
|
|
305
|
+
return "unknown"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# AFTER: Compiler catches missing cases
|
|
309
|
+
def status_message_good(status: Status) -> str:
|
|
310
|
+
"""
|
|
311
|
+
Good: Exhaustive match with assert_never.
|
|
312
|
+
|
|
313
|
+
If a new status is added, type checker reports an error
|
|
314
|
+
because assert_never expects type Never, but gets the new status.
|
|
315
|
+
|
|
316
|
+
>>> status_message_good(Status.PENDING)
|
|
317
|
+
'Waiting to start'
|
|
318
|
+
>>> status_message_good(Status.DONE)
|
|
319
|
+
'Completed successfully'
|
|
320
|
+
>>> status_message_good(Status.FAILED)
|
|
321
|
+
'Task failed'
|
|
322
|
+
"""
|
|
323
|
+
match status:
|
|
324
|
+
case Status.PENDING:
|
|
325
|
+
return "Waiting to start"
|
|
326
|
+
case Status.RUNNING:
|
|
327
|
+
return "In progress"
|
|
328
|
+
case Status.DONE:
|
|
329
|
+
return "Completed successfully"
|
|
330
|
+
case Status.FAILED:
|
|
331
|
+
return "Task failed"
|
|
332
|
+
case _:
|
|
333
|
+
assert_never(status) # Type error if cases are missing!
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# =============================================================================
|
|
337
|
+
# Pattern 6: Smart Constructor (P1)
|
|
338
|
+
# =============================================================================
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# BEFORE: Can create invalid objects
|
|
342
|
+
@dataclass
|
|
343
|
+
class EmailBad:
|
|
344
|
+
"""
|
|
345
|
+
Bad: No validation at construction.
|
|
346
|
+
|
|
347
|
+
EmailBad("not-an-email") creates an invalid object.
|
|
348
|
+
Validation happens elsewhere, if at all.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
value: str
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# AFTER: Validation at construction
|
|
355
|
+
@dataclass(frozen=True)
|
|
356
|
+
class Email:
|
|
357
|
+
"""
|
|
358
|
+
Good: Smart constructor validates on creation.
|
|
359
|
+
|
|
360
|
+
Invalid emails can never exist - construction fails.
|
|
361
|
+
|
|
362
|
+
>>> Email.create("user@example.com")
|
|
363
|
+
Success(Email(_value='user@example.com'))
|
|
364
|
+
|
|
365
|
+
>>> Email.create("not-an-email")
|
|
366
|
+
Failure('Email must contain @')
|
|
367
|
+
|
|
368
|
+
>>> Email.create("")
|
|
369
|
+
Failure('Email cannot be empty')
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
_value: str
|
|
373
|
+
|
|
374
|
+
@classmethod
|
|
375
|
+
def create(cls, value: str) -> Result["Email", str]:
|
|
376
|
+
"""Validate and construct email."""
|
|
377
|
+
if not value:
|
|
378
|
+
return Failure("Email cannot be empty")
|
|
379
|
+
if "@" not in value:
|
|
380
|
+
return Failure("Email must contain @")
|
|
381
|
+
if "." not in value.split("@")[1]:
|
|
382
|
+
return Failure("Email domain must have a dot")
|
|
383
|
+
return Success(cls(_value=value))
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def value(self) -> str:
|
|
387
|
+
"""Expose validated value."""
|
|
388
|
+
return self._value
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# =============================================================================
|
|
392
|
+
# Pattern 7: Structured Error (P1)
|
|
393
|
+
# =============================================================================
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# BEFORE: String error messages with embedded data
|
|
397
|
+
def parse_bad(text: str, line: int) -> Result[str, str]:
|
|
398
|
+
"""
|
|
399
|
+
Bad: Error information embedded in string.
|
|
400
|
+
|
|
401
|
+
The line number is in the message but not accessible
|
|
402
|
+
for programmatic handling (highlighting, jumping to line).
|
|
403
|
+
"""
|
|
404
|
+
if not text:
|
|
405
|
+
return Failure(f"Parse error at line {line}: unexpected EOF")
|
|
406
|
+
return Success(text)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# AFTER: Structured error type
|
|
410
|
+
@dataclass(frozen=True)
|
|
411
|
+
class ParseError:
|
|
412
|
+
"""
|
|
413
|
+
Good: Structured error with accessible fields.
|
|
414
|
+
|
|
415
|
+
Code can extract line number for highlighting,
|
|
416
|
+
message for display, etc.
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
message: str
|
|
420
|
+
line: int
|
|
421
|
+
column: int = 0
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def parse_good(text: str, line: int) -> Result[str, ParseError]:
|
|
425
|
+
"""
|
|
426
|
+
Good: Structured error for programmatic handling.
|
|
427
|
+
|
|
428
|
+
>>> result = parse_good("", 42)
|
|
429
|
+
>>> isinstance(result, Failure)
|
|
430
|
+
True
|
|
431
|
+
>>> result.failure().line
|
|
432
|
+
42
|
|
433
|
+
>>> result.failure().message
|
|
434
|
+
'unexpected EOF'
|
|
435
|
+
|
|
436
|
+
>>> parse_good("valid", 1)
|
|
437
|
+
Success('valid')
|
|
438
|
+
"""
|
|
439
|
+
if not text:
|
|
440
|
+
return Failure(ParseError(message="unexpected EOF", line=line))
|
|
441
|
+
return Success(text)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# =============================================================================
|
|
445
|
+
# Pattern 8: Optional → Result Conversion
|
|
446
|
+
# =============================================================================
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def find_user_bad(user_id: str) -> dict | None:
|
|
450
|
+
"""
|
|
451
|
+
Bad: Optional return hides error reason.
|
|
452
|
+
|
|
453
|
+
Caller can't distinguish "not found" from "invalid id" from "db error".
|
|
454
|
+
"""
|
|
455
|
+
# Demo: return None for missing
|
|
456
|
+
if not user_id:
|
|
457
|
+
return None
|
|
458
|
+
return {"id": user_id, "name": "Demo"}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@dataclass(frozen=True)
|
|
462
|
+
class UserNotFoundError:
|
|
463
|
+
"""Specific error: user doesn't exist."""
|
|
464
|
+
|
|
465
|
+
user_id: str
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@dataclass(frozen=True)
|
|
469
|
+
class InvalidUserIdError:
|
|
470
|
+
"""Specific error: invalid ID format."""
|
|
471
|
+
|
|
472
|
+
user_id: str
|
|
473
|
+
reason: str
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
UserError = UserNotFoundError | InvalidUserIdError
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def find_user_good(user_id: str) -> Result[dict, UserError]:
|
|
480
|
+
"""
|
|
481
|
+
Good: Result with specific error types.
|
|
482
|
+
|
|
483
|
+
>>> find_user_good("")
|
|
484
|
+
Failure(InvalidUserIdError(user_id='', reason='empty id'))
|
|
485
|
+
|
|
486
|
+
>>> find_user_good("unknown")
|
|
487
|
+
Failure(UserNotFoundError(user_id='unknown'))
|
|
488
|
+
|
|
489
|
+
>>> find_user_good("user123")
|
|
490
|
+
Success({'id': 'user123', 'name': 'Demo'})
|
|
491
|
+
"""
|
|
492
|
+
if not user_id:
|
|
493
|
+
return Failure(InvalidUserIdError(user_id=user_id, reason="empty id"))
|
|
494
|
+
if user_id == "unknown":
|
|
495
|
+
return Failure(UserNotFoundError(user_id=user_id))
|
|
496
|
+
return Success({"id": user_id, "name": "Demo"})
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# =============================================================================
|
|
500
|
+
# Pattern 9: try/except → Result Conversion
|
|
501
|
+
# =============================================================================
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def parse_json_bad(text: str) -> dict:
|
|
505
|
+
"""
|
|
506
|
+
Bad: Exceptions for expected errors.
|
|
507
|
+
|
|
508
|
+
JSON parsing failure is expected (user input), not exceptional.
|
|
509
|
+
"""
|
|
510
|
+
return json.loads(text) # Raises JSONDecodeError
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@dataclass(frozen=True)
|
|
514
|
+
class JsonParseError:
|
|
515
|
+
"""Structured JSON parse error."""
|
|
516
|
+
|
|
517
|
+
message: str
|
|
518
|
+
position: int
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def parse_json_good(text: str) -> Result[dict, JsonParseError]:
|
|
522
|
+
"""
|
|
523
|
+
Good: Result for expected failures.
|
|
524
|
+
|
|
525
|
+
>>> parse_json_good('{"a": 1}')
|
|
526
|
+
Success({'a': 1})
|
|
527
|
+
|
|
528
|
+
>>> result = parse_json_good('invalid')
|
|
529
|
+
>>> isinstance(result, Failure)
|
|
530
|
+
True
|
|
531
|
+
>>> result.failure().message
|
|
532
|
+
'Expecting value: line 1 column 1 (char 0)'
|
|
533
|
+
"""
|
|
534
|
+
try:
|
|
535
|
+
return Success(json.loads(text))
|
|
536
|
+
except json.JSONDecodeError as e:
|
|
537
|
+
return Failure(JsonParseError(message=str(e), position=e.pos))
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# =============================================================================
|
|
541
|
+
# Pattern 10: Result Chaining with bind/map
|
|
542
|
+
# =============================================================================
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def process_pipeline_bad(raw: str) -> str:
|
|
546
|
+
"""
|
|
547
|
+
Bad: Nested try/except for sequential operations.
|
|
548
|
+
|
|
549
|
+
Error handling scattered, hard to follow the happy path.
|
|
550
|
+
"""
|
|
551
|
+
try:
|
|
552
|
+
data = json.loads(raw)
|
|
553
|
+
except json.JSONDecodeError:
|
|
554
|
+
raise ValueError("Invalid JSON")
|
|
555
|
+
|
|
556
|
+
try:
|
|
557
|
+
name = data["user"]["name"]
|
|
558
|
+
except KeyError:
|
|
559
|
+
raise ValueError("Missing user.name")
|
|
560
|
+
|
|
561
|
+
return name.upper()
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def extract_name(data: dict) -> Result[str, str]:
|
|
565
|
+
"""Extract user.name from dict."""
|
|
566
|
+
try:
|
|
567
|
+
return Success(data["user"]["name"])
|
|
568
|
+
except KeyError:
|
|
569
|
+
return Failure("Missing user.name")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def process_pipeline_good(raw: str) -> Result[str, str]:
|
|
573
|
+
"""
|
|
574
|
+
Good: Result chaining for sequential operations.
|
|
575
|
+
|
|
576
|
+
Happy path is clear: parse → extract → transform.
|
|
577
|
+
Errors propagate automatically.
|
|
578
|
+
|
|
579
|
+
>>> process_pipeline_good('{"user": {"name": "alice"}}')
|
|
580
|
+
Success('ALICE')
|
|
581
|
+
|
|
582
|
+
>>> process_pipeline_good('invalid json')
|
|
583
|
+
Failure('Expecting value: line 1 column 1 (char 0)')
|
|
584
|
+
|
|
585
|
+
>>> process_pipeline_good('{"other": 1}')
|
|
586
|
+
Failure('Missing user.name')
|
|
587
|
+
"""
|
|
588
|
+
# Parse JSON → extract name → uppercase
|
|
589
|
+
# Each step returns Result, errors propagate automatically
|
|
590
|
+
try:
|
|
591
|
+
data = json.loads(raw)
|
|
592
|
+
except json.JSONDecodeError as e:
|
|
593
|
+
return Failure(str(e))
|
|
594
|
+
|
|
595
|
+
return extract_name(data).map(lambda name: name.upper())
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# =============================================================================
|
|
599
|
+
# Summary: When to Use Each Pattern
|
|
600
|
+
# =============================================================================
|
|
601
|
+
|
|
602
|
+
# | Pattern | Use When |
|
|
603
|
+
# |-------------------|---------------------------------------------|
|
|
604
|
+
# | NewType | 3+ params of same primitive type |
|
|
605
|
+
# | Validation | Multiple independent validations |
|
|
606
|
+
# | NonEmpty | Functions that require non-empty input |
|
|
607
|
+
# | Literal | Parameter with finite valid values |
|
|
608
|
+
# | ExhaustiveMatch | Matching on enums |
|
|
609
|
+
# | SmartConstructor | Types with invariants |
|
|
610
|
+
# | StructuredError | Errors with metadata (line, column, etc.) |
|
|
611
|
+
# | Optional→Result | Functions returning None for failures |
|
|
612
|
+
# | try/except→Result | Wrapping exceptions as Result |
|
|
613
|
+
# | Result Chaining | Sequential operations with error propagation|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Invar Examples (TypeScript)
|
|
2
|
+
|
|
3
|
+
Reference patterns for TypeScript projects using the Invar Protocol.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
| File | Purpose |
|
|
8
|
+
|------|---------|
|
|
9
|
+
| `contracts.ts` | Zod schema patterns for pre/postconditions |
|
|
10
|
+
| `core_shell.ts` | Core/Shell separation with neverthrow |
|
|
11
|
+
| `functional.ts` | Functional patterns (Branded Types, NonEmpty, Exhaustive Match) |
|
|
12
|
+
| `workflow.md` | Complete USBV workflow example |
|
|
13
|
+
|
|
14
|
+
## Dependencies
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install zod neverthrow
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Reference
|
|
21
|
+
|
|
22
|
+
| Concept | TypeScript Pattern |
|
|
23
|
+
|---------|-------------------|
|
|
24
|
+
| Precondition | `z.number().positive()` |
|
|
25
|
+
| Postcondition | Return type validation |
|
|
26
|
+
| Examples | JSDoc `@example` blocks |
|
|
27
|
+
| Shell errors | `Result<T, E>` from neverthrow |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
*Managed by Invar - regenerated on `invar update`*
|