opencode-effect-enforcer 0.2.0
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.
- package/LICENSE +21 -0
- package/README.md +278 -0
- package/guidance/effect-first-development.md +1247 -0
- package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
- package/guidance/post__parse-dont-validate.md +109 -0
- package/guidance/progressive-disclosure-guidance.md +38 -0
- package/package.json +63 -0
- package/patterns/avoid-any.md +37 -0
- package/patterns/avoid-data-tagged-error.md +34 -0
- package/patterns/avoid-direct-json.md +51 -0
- package/patterns/avoid-direct-tag-checks.md +54 -0
- package/patterns/avoid-expect-in-if.md +52 -0
- package/patterns/avoid-mutable-state.md +70 -0
- package/patterns/avoid-native-fetch.md +61 -0
- package/patterns/avoid-node-imports.md +86 -0
- package/patterns/avoid-non-null-assertion.md +44 -0
- package/patterns/avoid-object-type.md +46 -0
- package/patterns/avoid-option-getorthrow.md +39 -0
- package/patterns/avoid-platform-coupling.md +43 -0
- package/patterns/avoid-process-env.md +43 -0
- package/patterns/avoid-react-hooks.md +73 -0
- package/patterns/avoid-schema-suffix.md +45 -0
- package/patterns/avoid-sync-fs.md +68 -0
- package/patterns/avoid-try-catch.md +47 -0
- package/patterns/avoid-ts-ignore.md +38 -0
- package/patterns/avoid-untagged-errors.md +67 -0
- package/patterns/avoid-yield-ref.md +46 -0
- package/patterns/casting-awareness.md +46 -0
- package/patterns/context-tag-extends.md +84 -0
- package/patterns/effect-catchall-default.md +61 -0
- package/patterns/effect-promise-vs-trypromise.md +47 -0
- package/patterns/effect-run-in-body.md +58 -0
- package/patterns/imperative-loops.md +76 -0
- package/patterns/prefer-arr-sort.md +52 -0
- package/patterns/prefer-duration-values.md +56 -0
- package/patterns/prefer-effect-fn.md +161 -0
- package/patterns/prefer-match-over-switch.md +48 -0
- package/patterns/prefer-option-over-null.md +56 -0
- package/patterns/prefer-redacted-config.md +70 -0
- package/patterns/prefer-schema-class.md +54 -0
- package/patterns/require-effect-concurrency.md +83 -0
- package/patterns/stream-large-files.md +63 -0
- package/patterns/throw-in-effect-gen.md +62 -0
- package/patterns/use-clock-service.md +45 -0
- package/patterns/use-command-executor-service.md +54 -0
- package/patterns/use-console-service.md +54 -0
- package/patterns/use-filesystem-service.md +59 -0
- package/patterns/use-http-client-service.md +77 -0
- package/patterns/use-path-service.md +53 -0
- package/patterns/use-random-service.md +45 -0
- package/patterns/use-temp-file-scoped.md +66 -0
- package/patterns/vm-in-wrong-file.md +51 -0
- package/patterns/yield-in-for-loop.md +61 -0
- package/skills/effect-ai-chat/SKILL.md +472 -0
- package/skills/effect-ai-language-model/SKILL.md +652 -0
- package/skills/effect-ai-prompt/SKILL.md +752 -0
- package/skills/effect-ai-provider/SKILL.md +668 -0
- package/skills/effect-ai-streaming/SKILL.md +418 -0
- package/skills/effect-ai-tool/SKILL.md +1132 -0
- package/skills/effect-atom-rpc/SKILL.md +488 -0
- package/skills/effect-atom-state/SKILL.md +640 -0
- package/skills/effect-batching/SKILL.md +614 -0
- package/skills/effect-cache/SKILL.md +570 -0
- package/skills/effect-cli/SKILL.md +523 -0
- package/skills/effect-command-executor/SKILL.md +675 -0
- package/skills/effect-concurrency-testing/SKILL.md +612 -0
- package/skills/effect-config/SKILL.md +580 -0
- package/skills/effect-context-witness/SKILL.md +274 -0
- package/skills/effect-domain-modeling/SKILL.md +1212 -0
- package/skills/effect-domain-predicates/SKILL.md +867 -0
- package/skills/effect-error-handling/SKILL.md +1581 -0
- package/skills/effect-fiber/SKILL.md +731 -0
- package/skills/effect-filesystem/SKILL.md +624 -0
- package/skills/effect-graph/SKILL.md +571 -0
- package/skills/effect-http-api/SKILL.md +1760 -0
- package/skills/effect-http-client/SKILL.md +989 -0
- package/skills/effect-http-server/SKILL.md +920 -0
- package/skills/effect-incremental-migration/SKILL.md +362 -0
- package/skills/effect-layer-design/SKILL.md +642 -0
- package/skills/effect-managed-runtime/SKILL.md +395 -0
- package/skills/effect-mcp-server/SKILL.md +608 -0
- package/skills/effect-observability/SKILL.md +719 -0
- package/skills/effect-optics/SKILL.md +554 -0
- package/skills/effect-parallelization/SKILL.md +668 -0
- package/skills/effect-path/SKILL.md +296 -0
- package/skills/effect-pattern-matching/SKILL.md +914 -0
- package/skills/effect-platform-abstraction/SKILL.md +1175 -0
- package/skills/effect-platform-layers/SKILL.md +514 -0
- package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
- package/skills/effect-react-composition/SKILL.md +986 -0
- package/skills/effect-react-vm/SKILL.md +675 -0
- package/skills/effect-rpc-api/SKILL.md +624 -0
- package/skills/effect-rpc-client/SKILL.md +666 -0
- package/skills/effect-rpc-cluster/SKILL.md +1623 -0
- package/skills/effect-rpc-server/SKILL.md +767 -0
- package/skills/effect-scheduling/SKILL.md +124 -0
- package/skills/effect-schema-composition/SKILL.md +975 -0
- package/skills/effect-schema-v4/SKILL.md +691 -0
- package/skills/effect-scope/SKILL.md +682 -0
- package/skills/effect-service-implementation/SKILL.md +656 -0
- package/skills/effect-socket/SKILL.md +703 -0
- package/skills/effect-sql/SKILL.md +781 -0
- package/skills/effect-stream/SKILL.md +765 -0
- package/skills/effect-testing/SKILL.md +1331 -0
- package/skills/effect-typeclass-design/SKILL.md +161 -0
- package/skills/effect-wide-events/Article.md +66 -0
- package/skills/effect-wide-events/SKILL.md +95 -0
- package/skills/effect-workflow/SKILL.md +810 -0
- package/src/agent-policy.ts +22 -0
- package/src/enforcer.ts +104 -0
- package/src/frontmatter.ts +34 -0
- package/src/guidance.ts +66 -0
- package/src/index.ts +38 -0
- package/src/pattern-catalog.ts +115 -0
- package/src/pattern-matcher.ts +178 -0
- package/src/pattern.ts +97 -0
- package/src/skills.ts +29 -0
- package/src/write-projection.ts +66 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
action: context
|
|
3
|
+
tool: (edit|write)
|
|
4
|
+
event: after
|
|
5
|
+
name: effect-run-in-body
|
|
6
|
+
description: Effect.runSync/runPromise/runFork should only be at entry points
|
|
7
|
+
glob: '**/*.{ts,tsx}'
|
|
8
|
+
detector: ast
|
|
9
|
+
pattern:
|
|
10
|
+
- Effect.runSync
|
|
11
|
+
- Effect.runPromise
|
|
12
|
+
- Effect.runFork
|
|
13
|
+
level: warning
|
|
14
|
+
suggestSkills:
|
|
15
|
+
- effect-managed-runtime
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Effect.runSync / runPromise / runFork Only at Entry Points
|
|
19
|
+
|
|
20
|
+
```haskell
|
|
21
|
+
-- Transformation
|
|
22
|
+
runSync :: Effect a E R → a -- escapes Effect, loses composition
|
|
23
|
+
runPromise :: Effect a E R → Promise a -- same problem
|
|
24
|
+
runFork :: Effect a E R → Fiber a -- same problem, async variant
|
|
25
|
+
|
|
26
|
+
-- Instead: compose until boundary
|
|
27
|
+
compose :: Effect a E R → Effect b E R → Effect (a, b) E R
|
|
28
|
+
yield* :: Effect a E R → a -- inside Effect.gen only
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```haskell
|
|
32
|
+
-- Pattern
|
|
33
|
+
bad :: Effect () R
|
|
34
|
+
bad = do
|
|
35
|
+
result ← pure $ Effect.runSync someEffect -- breaks composition!
|
|
36
|
+
doSomething result
|
|
37
|
+
-- can't retry, race, timeout the inner effect
|
|
38
|
+
|
|
39
|
+
good :: Effect () R
|
|
40
|
+
good = do
|
|
41
|
+
result ← someEffect -- still composable
|
|
42
|
+
doSomething result
|
|
43
|
+
-- can wrap entire program with retry, race, timeout
|
|
44
|
+
|
|
45
|
+
-- Entry points only
|
|
46
|
+
main :: IO ()
|
|
47
|
+
main = Effect.runMain program -- ✓ application boundary
|
|
48
|
+
|
|
49
|
+
handler :: Request → IO Response
|
|
50
|
+
handler req = Effect.runPromise (handle req) -- ✓ API boundary
|
|
51
|
+
|
|
52
|
+
test :: Spec
|
|
53
|
+
test = Effect.runPromise (testProgram) -- ✓ test boundary
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Running effects mid-logic breaks composition. Keep effects as values until entry points (main, handlers, tests).
|
|
57
|
+
|
|
58
|
+
`Effect.runFork` is just as much a runtime escape as `runSync` / `runPromise`: it materialises a `Fiber` at the call site instead of composing with `Effect.forkChild` / `Effect.forkDetach` (which stay inside the Effect world and respect structural concurrency). Reserve all three for the entrypoint / test harness boundary.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
action: context
|
|
3
|
+
tool: (edit|write)
|
|
4
|
+
event: after
|
|
5
|
+
name: imperative-loops
|
|
6
|
+
description: Use functional transformations instead of imperative loops (for / while / do-while)
|
|
7
|
+
glob: '**/*.{ts,tsx}'
|
|
8
|
+
detector: ast
|
|
9
|
+
rule:
|
|
10
|
+
any:
|
|
11
|
+
- kind: for_statement
|
|
12
|
+
- kind: for_in_statement
|
|
13
|
+
- kind: while_statement
|
|
14
|
+
- kind: do_statement
|
|
15
|
+
level: warning
|
|
16
|
+
suggestSkills:
|
|
17
|
+
- effect-parallelization
|
|
18
|
+
- effect-scheduling
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# Use Functional Transformations
|
|
22
|
+
|
|
23
|
+
```haskell
|
|
24
|
+
-- Array operations
|
|
25
|
+
map :: [a] → (a → b) → [b]
|
|
26
|
+
filter :: [a] → (a → Bool) → [a]
|
|
27
|
+
reduce :: [a] → b → (b → a → b) → b
|
|
28
|
+
filterMap :: [a] → (a → Option b) → [b] -- single pass
|
|
29
|
+
|
|
30
|
+
-- Record operations
|
|
31
|
+
Record.map :: {k: a} → (a → b) → {k: b}
|
|
32
|
+
Record.filter :: {k: a} → (a → Bool) → {k: a}
|
|
33
|
+
Record.filterMap :: {k: a} → (a → Option b) → {k: b}
|
|
34
|
+
|
|
35
|
+
-- Effectful iteration
|
|
36
|
+
Effect.forEach :: [a] → (a → Effect b) → Effect [b]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```haskell
|
|
40
|
+
-- Bad: Imperative with mutations (any flavor of loop)
|
|
41
|
+
bad₁ :: [Number] → [Number]
|
|
42
|
+
bad₁ numbers = do
|
|
43
|
+
result ← []
|
|
44
|
+
for n in numbers do -- ✗ ForOf
|
|
45
|
+
if n % 2 == 0 then result.push(n * n)
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
bad₂ :: Effect ()
|
|
49
|
+
bad₂ = do
|
|
50
|
+
while (not done) do -- ✗ While
|
|
51
|
+
yield* step
|
|
52
|
+
|
|
53
|
+
bad₃ :: Effect ()
|
|
54
|
+
bad₃ = do
|
|
55
|
+
repeat -- ✗ DoWhile
|
|
56
|
+
yield* step
|
|
57
|
+
until done
|
|
58
|
+
|
|
59
|
+
-- Good: Functional composition
|
|
60
|
+
good :: [Number] → [Number]
|
|
61
|
+
good numbers =
|
|
62
|
+
filterMap numbers λn →
|
|
63
|
+
if n % 2 == 0
|
|
64
|
+
then Option.some(n * n)
|
|
65
|
+
else Option.none()
|
|
66
|
+
|
|
67
|
+
goodEffect :: [Item] → Effect ()
|
|
68
|
+
goodEffect items = Effect.forEach items processItem { concurrency: 4 }
|
|
69
|
+
|
|
70
|
+
goodRecurring :: Effect ()
|
|
71
|
+
goodRecurring = step & Effect.repeat (Schedule.spaced (Duration.seconds 1))
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Imperative loops — `for`, `for ... in`, `for ... of`, `while`, `do ... while` — encourage mutation and break composition. Use `Arr.map` / `Arr.filter` / `Arr.filterMap` / `Arr.reduce` for pure transformations, `Effect.forEach` for effectful iteration (with explicit `concurrency`), and `Effect.repeat` plus `Schedule` for recurring effects.
|
|
75
|
+
|
|
76
|
+
Note: in tree-sitter TypeScript, `for ... of` and `for ... in` share the same AST kind (`for_in_statement`), so listing `for_in_statement` covers both.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
action: context
|
|
3
|
+
tool: (edit|write)
|
|
4
|
+
event: after
|
|
5
|
+
name: prefer-arr-sort
|
|
6
|
+
description: Use Arr.sort with explicit Order instead of native Array.prototype.sort
|
|
7
|
+
glob: '**/*.{ts,tsx}'
|
|
8
|
+
detector: ast
|
|
9
|
+
rule:
|
|
10
|
+
pattern: $A.sort($$$)
|
|
11
|
+
not:
|
|
12
|
+
pattern: Arr.sort($$$)
|
|
13
|
+
level: warning
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Use `Arr.sort` with Explicit `Order`
|
|
17
|
+
|
|
18
|
+
```haskell
|
|
19
|
+
-- Transformation
|
|
20
|
+
native :: [a] -> (a -> a -> Number) -> [a] -- mutates in place, untyped comparator
|
|
21
|
+
arrSort :: [a] -> Order a -> [a] -- pure, typed, composable
|
|
22
|
+
|
|
23
|
+
-- Pattern
|
|
24
|
+
bad :: [User] -> [User]
|
|
25
|
+
bad users = users.sort((a, b) => a.name.localeCompare(b.name))
|
|
26
|
+
-- mutates original, comparator is untyped
|
|
27
|
+
|
|
28
|
+
good :: [User] -> [User]
|
|
29
|
+
good users = Arr.sort(users, byName)
|
|
30
|
+
where byName = Order.mapInput(Order.String, (u: User) => u.name)
|
|
31
|
+
-- pure copy, typed ordering, composable
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```haskell
|
|
35
|
+
-- Composing orders
|
|
36
|
+
byNameThenAge :: Order User
|
|
37
|
+
byNameThenAge = Order.combine(
|
|
38
|
+
Order.mapInput(Order.String, _.name),
|
|
39
|
+
Order.mapInput(Order.Number, _.age)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
sorted :: [User] -> [User]
|
|
43
|
+
sorted = Arr.sort byNameThenAge
|
|
44
|
+
|
|
45
|
+
-- Reverse
|
|
46
|
+
descending :: [User] -> [User]
|
|
47
|
+
descending = Arr.sort (Order.reverse byName)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Native `.sort()` mutates the array in place and uses an untyped comparator. `Arr.sort` from `effect/Array` returns a new sorted array using a composable, typed `Order`.
|
|
51
|
+
|
|
52
|
+
References: EF-38 in effect-first-development.md
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
action: context
|
|
3
|
+
tool: (edit|write)
|
|
4
|
+
event: after
|
|
5
|
+
name: prefer-duration-values
|
|
6
|
+
description: Use Duration helpers instead of numeric duration literals
|
|
7
|
+
glob: '**/*.{ts,tsx}'
|
|
8
|
+
detector: ast
|
|
9
|
+
rule:
|
|
10
|
+
any:
|
|
11
|
+
- pattern: 'Effect.sleep($DURATION)'
|
|
12
|
+
- pattern: 'Effect.delay($EFFECT, $DURATION)'
|
|
13
|
+
- pattern: 'Effect.delay($DURATION)'
|
|
14
|
+
- pattern: 'Effect.timeout($EFFECT, $DURATION)'
|
|
15
|
+
- pattern: 'Effect.timeout($DURATION)'
|
|
16
|
+
- pattern: 'Effect.timeoutOption($EFFECT, $DURATION)'
|
|
17
|
+
- pattern: 'Effect.timeoutOption($DURATION)'
|
|
18
|
+
- pattern: 'Effect.timeoutOrElse($EFFECT, { duration: $DURATION, $$$REST })'
|
|
19
|
+
- pattern: 'Effect.timeoutOrElse({ duration: $DURATION, $$$REST })'
|
|
20
|
+
- pattern: 'Schedule.duration($DURATION)'
|
|
21
|
+
- pattern: 'Schedule.during($DURATION)'
|
|
22
|
+
- pattern: 'Schedule.exponential($DURATION)'
|
|
23
|
+
- pattern: 'Schedule.fibonacci($DURATION)'
|
|
24
|
+
- pattern: 'Schedule.fixed($DURATION)'
|
|
25
|
+
- pattern: 'Schedule.spaced($DURATION)'
|
|
26
|
+
- pattern: 'Schedule.windowed($DURATION)'
|
|
27
|
+
constraints:
|
|
28
|
+
DURATION:
|
|
29
|
+
kind: number
|
|
30
|
+
level: warning
|
|
31
|
+
suggestSkills:
|
|
32
|
+
- effect-scheduling
|
|
33
|
+
- effect-testing
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
# Prefer `Duration` Values
|
|
37
|
+
|
|
38
|
+
```haskell
|
|
39
|
+
-- Transformation
|
|
40
|
+
number :: milliseconds? seconds? unclear
|
|
41
|
+
Duration :: explicit time unit
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
// Bad
|
|
46
|
+
Effect.sleep(1000);
|
|
47
|
+
program.pipe(Effect.timeout(5000));
|
|
48
|
+
Schedule.spaced(250);
|
|
49
|
+
|
|
50
|
+
// Good
|
|
51
|
+
Effect.sleep(Duration.seconds(1));
|
|
52
|
+
program.pipe(Effect.timeout(Duration.seconds(5)));
|
|
53
|
+
Schedule.spaced(Duration.millis(250));
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Numeric duration literals obscure units and make timeout/retry policies harder to review. Use `Duration.millis`, `Duration.seconds`, or another `effect/Duration` constructor so time windows are explicit.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
---
|
|
2
|
+
action: context
|
|
3
|
+
tool: (edit|write)
|
|
4
|
+
event: after
|
|
5
|
+
name: prefer-effect-fn
|
|
6
|
+
description: Service methods should use Effect.fn for automatic tracing instead of plain Effect.gen wrappers
|
|
7
|
+
glob: '**/*.{ts,tsx}'
|
|
8
|
+
detector: ast
|
|
9
|
+
rule:
|
|
10
|
+
any:
|
|
11
|
+
- pattern: ($$$ARGS) => Effect.gen($$$BODY)
|
|
12
|
+
- pattern: '$NAME: ($$$ARGS) => Effect.gen($$$BODY)'
|
|
13
|
+
- all:
|
|
14
|
+
- kind: method_definition
|
|
15
|
+
- has:
|
|
16
|
+
field: body
|
|
17
|
+
regex: Effect\.gen
|
|
18
|
+
- all:
|
|
19
|
+
- kind: pair
|
|
20
|
+
- has:
|
|
21
|
+
pattern: function($$$ARGS) { return Effect.gen($$$BODY) }
|
|
22
|
+
inside:
|
|
23
|
+
any:
|
|
24
|
+
- pattern: Layer.effect($$$)
|
|
25
|
+
- pattern: Layer.effectContext($$$)
|
|
26
|
+
- pattern: Layer.succeed($$$)
|
|
27
|
+
stopBy: end
|
|
28
|
+
not:
|
|
29
|
+
inside:
|
|
30
|
+
pattern: Effect.fn($$$)($$$)
|
|
31
|
+
stopBy: end
|
|
32
|
+
level: warning
|
|
33
|
+
suggestSkills:
|
|
34
|
+
- effect-service-implementation
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
# Prefer `Effect.fn` for Service Methods
|
|
38
|
+
|
|
39
|
+
```haskell
|
|
40
|
+
-- Transformation
|
|
41
|
+
Effect.gen :: (() -> Generator) -> Effect -- no tracing, anonymous
|
|
42
|
+
Effect.fn :: String -> (...args -> Effect) -- named, auto-traced span
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```haskell
|
|
46
|
+
-- Pattern
|
|
47
|
+
bad :: Service method
|
|
48
|
+
bad = {
|
|
49
|
+
getUser: (id: UserId) =>
|
|
50
|
+
Effect.gen(function* () { -- anonymous, no span
|
|
51
|
+
...
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
good :: Service method
|
|
56
|
+
good = {
|
|
57
|
+
getUser: Effect.fn("UserService.getUser")(
|
|
58
|
+
(id: UserId) => -- named span, auto-traced
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
...
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`Effect.fn` wraps a function to automatically create a traced span with the given name. Use it for all service method implementations to get observability for free.
|
|
67
|
+
|
|
68
|
+
## Format
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
const methodName = Effect.fn('ServiceName.methodName')(function* (
|
|
72
|
+
arg1: Type1,
|
|
73
|
+
arg2: Type2
|
|
74
|
+
) {
|
|
75
|
+
// implementation using yield*
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Key details:
|
|
80
|
+
|
|
81
|
+
- **Naming convention**: `"ServiceName.methodName"` — matches the service class and method
|
|
82
|
+
- **Generator shorthand**: Pass a generator function directly to `Effect.fn` — no need for an intermediate arrow wrapping `Effect.gen`
|
|
83
|
+
- **Wraps the function**: `Effect.fn` takes the entire implementation function as a generator
|
|
84
|
+
|
|
85
|
+
## Complete Before/After
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
// BEFORE — plain arrow functions, no tracing
|
|
89
|
+
export class UserRepository extends Context.Service<UserRepository>()(
|
|
90
|
+
'@services/UserRepository',
|
|
91
|
+
{
|
|
92
|
+
make: Effect.gen(function* () {
|
|
93
|
+
const db = yield* DatabaseClient;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
findById: (id: string): Effect.Effect<User, UserNotFound> =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const row = yield* db.query(
|
|
99
|
+
'SELECT * FROM users WHERE id = ?',
|
|
100
|
+
id
|
|
101
|
+
);
|
|
102
|
+
if (!row)
|
|
103
|
+
return yield* new UserNotFound({
|
|
104
|
+
id,
|
|
105
|
+
message: `Not found: ${id}`
|
|
106
|
+
});
|
|
107
|
+
return row as User;
|
|
108
|
+
}),
|
|
109
|
+
|
|
110
|
+
create: (
|
|
111
|
+
data: CreateUserData
|
|
112
|
+
): Effect.Effect<User, DuplicateUser> =>
|
|
113
|
+
Effect.gen(function* () {
|
|
114
|
+
return yield* db.insert('users', data);
|
|
115
|
+
})
|
|
116
|
+
};
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
) {}
|
|
120
|
+
|
|
121
|
+
// AFTER — Effect.fn, every method gets a traced span
|
|
122
|
+
export class UserRepository extends Context.Service<UserRepository>()(
|
|
123
|
+
'@services/UserRepository',
|
|
124
|
+
{
|
|
125
|
+
make: Effect.gen(function* () {
|
|
126
|
+
const db = yield* DatabaseClient;
|
|
127
|
+
|
|
128
|
+
const findById = Effect.fn('UserRepository.findById')(
|
|
129
|
+
(id: string): Effect.Effect<User, UserNotFound> =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const row = yield* db.query(
|
|
132
|
+
'SELECT * FROM users WHERE id = ?',
|
|
133
|
+
id
|
|
134
|
+
);
|
|
135
|
+
if (!row)
|
|
136
|
+
return yield* new UserNotFound({
|
|
137
|
+
id,
|
|
138
|
+
message: `Not found: ${id}`
|
|
139
|
+
});
|
|
140
|
+
return row as User;
|
|
141
|
+
})
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const create = Effect.fn('UserRepository.create')(
|
|
145
|
+
(data: CreateUserData): Effect.Effect<User, DuplicateUser> =>
|
|
146
|
+
Effect.gen(function* () {
|
|
147
|
+
return yield* db.insert('users', data);
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return { findById, create };
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
) {}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## When NOT to use Effect.fn
|
|
158
|
+
|
|
159
|
+
- Top-level programs or scripts (not service methods)
|
|
160
|
+
- One-off effects that aren't part of a service interface
|
|
161
|
+
- Simple succeed/fail expressions that don't benefit from tracing
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
action: context
|
|
3
|
+
tool: (edit|write)
|
|
4
|
+
event: after
|
|
5
|
+
name: prefer-match-over-switch
|
|
6
|
+
description: Use Match from Effect instead of native switch statements
|
|
7
|
+
glob: '**/*.{ts,tsx}'
|
|
8
|
+
detector: ast
|
|
9
|
+
pattern: 'switch ($X) { $$$ }'
|
|
10
|
+
level: warning
|
|
11
|
+
suggestSkills:
|
|
12
|
+
- effect-pattern-matching
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Use `Match` Instead of `switch`
|
|
16
|
+
|
|
17
|
+
```haskell
|
|
18
|
+
-- Transformation
|
|
19
|
+
switch :: a -> { case₁: b, ..., default: b } -- non-exhaustive, fall-through risk
|
|
20
|
+
Match :: a -> Matcher a b -- exhaustive, composable, type-safe
|
|
21
|
+
|
|
22
|
+
-- Pattern
|
|
23
|
+
bad :: Phase -> String
|
|
24
|
+
bad phase = switch phase of
|
|
25
|
+
"idle" -> "waiting"
|
|
26
|
+
"running" -> "active"
|
|
27
|
+
_ -> "unknown" -- default hides new variants
|
|
28
|
+
|
|
29
|
+
good :: Phase -> String
|
|
30
|
+
good phase = Match.value(phase).pipe(
|
|
31
|
+
Match.when("idle", \_ -> "waiting"),
|
|
32
|
+
Match.when("running", \_ -> "active"),
|
|
33
|
+
Match.exhaustive -- compiler error if variant added
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```haskell
|
|
38
|
+
-- For tagged unions
|
|
39
|
+
matchTagged :: Event -> String
|
|
40
|
+
matchTagged = Match.value >>> pipe
|
|
41
|
+
Match.tag("Created", \e -> "new: " <> e.id)
|
|
42
|
+
Match.tag("Completed", \e -> "done: " <> e.id)
|
|
43
|
+
Match.exhaustive
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`switch` has fall-through semantics and non-exhaustive `default` cases that hide new variants at compile time. `Match` from Effect is exhaustive, composable, and type-safe — the compiler will error when new variants are added.
|
|
47
|
+
|
|
48
|
+
References: EF-7 in effect-first-development.md
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
action: context
|
|
3
|
+
tool: (edit|write)
|
|
4
|
+
event: after
|
|
5
|
+
name: prefer-option-over-null
|
|
6
|
+
description: Consider using Option instead of union with null or undefined
|
|
7
|
+
glob: '**/*.{ts,tsx}'
|
|
8
|
+
detector: ast
|
|
9
|
+
rule:
|
|
10
|
+
any:
|
|
11
|
+
- all:
|
|
12
|
+
- kind: literal_type
|
|
13
|
+
- regex: '^null$'
|
|
14
|
+
- inside:
|
|
15
|
+
kind: union_type
|
|
16
|
+
stopBy: neighbor
|
|
17
|
+
- all:
|
|
18
|
+
- kind: literal_type
|
|
19
|
+
- regex: '^undefined$'
|
|
20
|
+
- inside:
|
|
21
|
+
kind: union_type
|
|
22
|
+
stopBy: neighbor
|
|
23
|
+
level: info
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
# Consider `Option` Instead of `| null` / `| undefined`
|
|
27
|
+
|
|
28
|
+
```haskell
|
|
29
|
+
-- Transformation
|
|
30
|
+
nullable :: T | Null -- scattered null checks
|
|
31
|
+
undef :: T | Undefined -- same problem, different keyword
|
|
32
|
+
option :: Option T -- composable, chainable
|
|
33
|
+
|
|
34
|
+
-- Option operations
|
|
35
|
+
map :: (a → b) → Option a → Option b
|
|
36
|
+
flatMap :: (a → Option b) → Option a → Option b
|
|
37
|
+
filter :: (a → Bool) → Option a → Option a
|
|
38
|
+
getOrElse :: a → Option a → a
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```haskell
|
|
42
|
+
-- Pattern
|
|
43
|
+
bad :: Id → User | Null
|
|
44
|
+
bad id = users.get id -- caller must check null
|
|
45
|
+
|
|
46
|
+
good :: Id → Option User
|
|
47
|
+
good id = Option.fromNullishOr (users.get id)
|
|
48
|
+
|
|
49
|
+
-- Composition
|
|
50
|
+
findEmail :: Id → Option Email
|
|
51
|
+
findEmail = good >=> (_.email >>> Option.fromNullishOr)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`Option<T>` provides chainable operations. Use `| null` or `| undefined` only at external boundaries (JSON, DOM, third-party libs).
|
|
55
|
+
|
|
56
|
+
Both `| null` and `| undefined` carry the same modelling cost — every consumer needs a defensive check, and `Option`'s combinators (`map`, `flatMap`, `filter`, `getOrElse`, `match`) replace those checks with a single composable shape. `| null | undefined` is the worst of both; convert it at the boundary with `Option.fromNullishOr`.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
action: context
|
|
3
|
+
tool: (edit|write)
|
|
4
|
+
event: after
|
|
5
|
+
name: prefer-redacted-config
|
|
6
|
+
description: Use Config.redacted or Schema.Redacted for secret-like configuration values
|
|
7
|
+
glob: '**/*.{ts,tsx}'
|
|
8
|
+
detector: ast
|
|
9
|
+
rule:
|
|
10
|
+
any:
|
|
11
|
+
- pattern: Config.string($KEY)
|
|
12
|
+
- pattern: Config.nonEmptyString($KEY)
|
|
13
|
+
- all:
|
|
14
|
+
- kind: pair
|
|
15
|
+
- has:
|
|
16
|
+
field: key
|
|
17
|
+
regex: '(?i)(api[_-]?key|auth[_-]?token|token|secret|password|passwd|private[_-]?key|client[_-]?secret|database[_-]?url|db[_-]?url|connection[_-]?string|dsn)'
|
|
18
|
+
- has:
|
|
19
|
+
field: value
|
|
20
|
+
regex: '^Schema\.(String|NonEmptyString)(\.|$)'
|
|
21
|
+
- inside:
|
|
22
|
+
pattern: Config.schema($$$)
|
|
23
|
+
stopBy: end
|
|
24
|
+
constraints:
|
|
25
|
+
KEY:
|
|
26
|
+
regex: '(?i)^["''][^"'']*(api[_-]?key|auth[_-]?token|token|secret|password|passwd|private[_-]?key|client[_-]?secret|database[_-]?url|db[_-]?url|connection[_-]?string|dsn)[^"'']*["'']$'
|
|
27
|
+
level: warning
|
|
28
|
+
suggestSkills:
|
|
29
|
+
- effect-config
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
# Prefer Redacted Config for Secrets
|
|
33
|
+
|
|
34
|
+
```haskell
|
|
35
|
+
-- Transformation
|
|
36
|
+
Config.string secretKey :: String -- easy to log accidentally
|
|
37
|
+
Config.redacted secretKey :: Redacted String -- hidden from logs/toString
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// Bad
|
|
42
|
+
const apiKey = Config.string('API_KEY');
|
|
43
|
+
const token = Config.nonEmptyString('GITHUB_TOKEN');
|
|
44
|
+
|
|
45
|
+
// Good
|
|
46
|
+
const apiKey = Config.redacted('API_KEY');
|
|
47
|
+
const token = Config.redacted('GITHUB_TOKEN');
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For structured config schemas, wrap secret-like string fields in `Schema.Redacted`:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// Bad
|
|
54
|
+
const AppConfig = Config.schema(
|
|
55
|
+
Schema.Struct({
|
|
56
|
+
apiKey: Schema.String,
|
|
57
|
+
password: Schema.NonEmptyString
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Good
|
|
62
|
+
const AppConfig = Config.schema(
|
|
63
|
+
Schema.Struct({
|
|
64
|
+
apiKey: Schema.Redacted(Schema.String),
|
|
65
|
+
password: Schema.Redacted(Schema.String)
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Secrets should remain redacted from the moment they enter the program. Use `Config.redacted` for primitive config values and `Schema.Redacted(Schema.String)` for schema-based config fields.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
action: context
|
|
3
|
+
tool: (edit|write)
|
|
4
|
+
event: after
|
|
5
|
+
name: prefer-schema-class
|
|
6
|
+
description: Review Schema.Struct used for decoded or domain objects; prefer Schema.Class when identity matters
|
|
7
|
+
glob: '**/*.{ts,tsx}'
|
|
8
|
+
detector: ast
|
|
9
|
+
pattern: Schema.Struct($$$)
|
|
10
|
+
level: info
|
|
11
|
+
suggestSkills:
|
|
12
|
+
- effect-domain-modeling
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Use `Schema.Class` Instead of `Schema.Struct`
|
|
16
|
+
|
|
17
|
+
```haskell
|
|
18
|
+
-- Transformation
|
|
19
|
+
Schema.Struct :: { fields } -> Schema { fields } -- anonymous, no constructor
|
|
20
|
+
Schema.Class :: String -> { fields } -> Class -- named, constructable, extensible
|
|
21
|
+
|
|
22
|
+
-- Pattern
|
|
23
|
+
bad :: Schema
|
|
24
|
+
bad = Schema.Struct({
|
|
25
|
+
id: Schema.String,
|
|
26
|
+
name: Schema.String
|
|
27
|
+
})
|
|
28
|
+
-- anonymous type, no constructor, no instanceof
|
|
29
|
+
|
|
30
|
+
good :: Schema
|
|
31
|
+
good = class User extends Schema.Class<User>("User")({
|
|
32
|
+
id: Schema.String,
|
|
33
|
+
name: Schema.String
|
|
34
|
+
}) {}
|
|
35
|
+
-- named type, constructor, instanceof, optional annotations when useful
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```haskell
|
|
39
|
+
-- Benefits of Schema.Class
|
|
40
|
+
construct :: User
|
|
41
|
+
construct = new User({ id: "1", name: "Alice" })
|
|
42
|
+
|
|
43
|
+
check :: unknown -> Bool
|
|
44
|
+
check = (x) => x instanceof User
|
|
45
|
+
|
|
46
|
+
extend :: Schema
|
|
47
|
+
extend = class Admin extends User.extend<Admin>("Admin")({
|
|
48
|
+
role: Schema.Literal("admin")
|
|
49
|
+
}) {}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`Schema.Struct` produces an anonymous schema without a constructor or `instanceof` support. `Schema.Class` provides a named type, constructor, extensibility, and optional annotation support when docs or introspection benefit from it. Prefer `Schema.Class` for decoded domain/API shapes, union members, and values that need identity. `Schema.Struct` remains appropriate for local structural composition, configuration internals, and schemas where class identity adds no value, so review this informational finding in context.
|
|
53
|
+
|
|
54
|
+
References: EF-3, EF-33 in effect-first-development.md
|