opencode-effect-enforcer 0.2.6 → 0.3.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.
Files changed (73) hide show
  1. package/README.md +3 -3
  2. package/docs/effect-4.0.0-changelog.md +3213 -0
  3. package/docs/effect-4.0.0.md +110 -0
  4. package/guidance/effect-first-development.md +15 -305
  5. package/guidance/progressive-disclosure-guidance.md +18 -24
  6. package/package.json +2 -2
  7. package/patterns/avoid-any.md +2 -2
  8. package/patterns/avoid-direct-json.md +6 -6
  9. package/patterns/avoid-native-fetch.md +8 -6
  10. package/patterns/avoid-node-imports.md +2 -2
  11. package/patterns/avoid-non-null-assertion.md +2 -2
  12. package/patterns/avoid-object-type.md +2 -2
  13. package/patterns/avoid-platform-coupling.md +1 -1
  14. package/patterns/avoid-process-env.md +3 -4
  15. package/patterns/avoid-ts-ignore.md +1 -1
  16. package/patterns/context-tag-extends.md +11 -8
  17. package/patterns/effect-promise-vs-trypromise.md +6 -7
  18. package/patterns/prefer-arr-sort.md +2 -2
  19. package/patterns/prefer-effect-fn.md +21 -65
  20. package/patterns/prefer-schema-class.md +4 -4
  21. package/patterns/throw-in-effect-gen.md +1 -1
  22. package/patterns/use-clock-service.md +4 -0
  23. package/patterns/use-command-executor-service.md +2 -2
  24. package/patterns/use-http-client-service.md +8 -6
  25. package/patterns/use-random-service.md +6 -7
  26. package/skills/effect-ai-chat/SKILL.md +13 -7
  27. package/skills/effect-ai-language-model/SKILL.md +50 -21
  28. package/skills/effect-ai-prompt/SKILL.md +25 -14
  29. package/skills/effect-ai-provider/SKILL.md +50 -22
  30. package/skills/effect-ai-streaming/SKILL.md +27 -12
  31. package/skills/effect-ai-tool/SKILL.md +37 -28
  32. package/skills/effect-atom-rpc/SKILL.md +57 -36
  33. package/skills/effect-atom-state/SKILL.md +57 -19
  34. package/skills/effect-batching/SKILL.md +5 -3
  35. package/skills/effect-cache/SKILL.md +19 -7
  36. package/skills/effect-cli/SKILL.md +17 -8
  37. package/skills/effect-command-executor/SKILL.md +115 -64
  38. package/skills/effect-concurrency-testing/SKILL.md +26 -6
  39. package/skills/effect-config/SKILL.md +53 -2
  40. package/skills/effect-context-witness/SKILL.md +6 -6
  41. package/skills/effect-domain-modeling/SKILL.md +8 -1
  42. package/skills/effect-error-handling/SKILL.md +15 -2
  43. package/skills/effect-fiber/SKILL.md +20 -25
  44. package/skills/effect-filesystem/SKILL.md +69 -57
  45. package/skills/effect-http-api/SKILL.md +72 -22
  46. package/skills/effect-http-client/SKILL.md +25 -21
  47. package/skills/effect-http-server/SKILL.md +51 -21
  48. package/skills/effect-incremental-migration/SKILL.md +17 -8
  49. package/skills/effect-layer-design/SKILL.md +8 -0
  50. package/skills/effect-managed-runtime/SKILL.md +6 -0
  51. package/skills/effect-mcp-server/SKILL.md +64 -24
  52. package/skills/effect-observability/SKILL.md +61 -15
  53. package/skills/effect-parallelization/SKILL.md +24 -7
  54. package/skills/effect-path/SKILL.md +8 -2
  55. package/skills/effect-platform-abstraction/SKILL.md +88 -66
  56. package/skills/effect-platform-layers/SKILL.md +68 -67
  57. package/skills/effect-pubsub-event-bus/SKILL.md +56 -60
  58. package/skills/effect-react-composition/SKILL.md +19 -6
  59. package/skills/effect-rpc-api/SKILL.md +24 -24
  60. package/skills/effect-rpc-client/SKILL.md +33 -28
  61. package/skills/effect-rpc-cluster/SKILL.md +122 -78
  62. package/skills/effect-rpc-server/SKILL.md +56 -20
  63. package/skills/effect-scheduling/SKILL.md +29 -1
  64. package/skills/effect-schema-composition/SKILL.md +31 -13
  65. package/skills/effect-schema-v4/SKILL.md +94 -10
  66. package/skills/effect-scope/SKILL.md +13 -5
  67. package/skills/effect-service-implementation/SKILL.md +1 -1
  68. package/skills/effect-socket/SKILL.md +52 -8
  69. package/skills/effect-sql/SKILL.md +67 -33
  70. package/skills/effect-stream/SKILL.md +50 -5
  71. package/skills/effect-testing/SKILL.md +91 -2
  72. package/skills/effect-workflow/SKILL.md +76 -39
  73. package/src/guidance.ts +0 -1
@@ -15,6 +15,9 @@ the yielded service and `get` / `contextEffect` / `contextEffectOption`: an entr
15
15
  can fail when reacquired after successful preloading. Invalidating an active
16
16
  RcMap/LayerMap entry releases it after its last borrower closes; a replacement
17
17
  entry remains independently owned.
18
+ Preload keys with zero `idleTimeToLive` are skipped, including the default TTL.
19
+ Set a non-zero TTL when construction must eagerly acquire and validate them;
20
+ otherwise acquisition failures surface on first use.
18
21
 
19
22
  The Effect v4 source is available at `~/.local/share/opencode/repos/github.com/Effect-TS/effect@main/`.
20
23
  Browse and read files there directly to look up APIs, types, and implementations.
@@ -47,7 +50,7 @@ interface Cache<Key, A, E = never, R = never> {
47
50
  How to think about it:
48
51
 
49
52
  - **Entries store the lookup `Exit`** — successes *and failures* are cached. A failed lookup keeps failing from cache until the entry expires, is invalidated, or is refreshed.
50
- - **Concurrent `get`s of the same missing key share one lookup.** The first caller runs the lookup on its own fiber; the rest await the same `Deferred`.
53
+ - **Concurrent `get`s of the same missing key share one lookup fiber.** Each caller waits independently; interrupting one waiter does not cancel work still needed by another. The last departing waiter interrupts a pending lookup.
51
54
  - **Insertion-ordered map = LRU.** Reads move the entry to the back; when capacity is exceeded the oldest entries are evicted.
52
55
  - **TTL is computed per entry** from the lookup `Exit` and the key, against the fiber's `Clock` — `TestClock` works. Expiry is lazy: entries are removed when next touched.
53
56
  - **`ScopedCache`** is the same model where each entry additionally owns a `Scope`: resources acquired during the lookup live exactly as long as the entry is cached.
@@ -191,7 +194,7 @@ If an older in-flight lookup is interrupted after `set` installs a newer value,
191
194
  yield* Cache.invalidate(cache, 'k'); // remove one key; no-op if absent
192
195
  yield* Cache.invalidateAll(cache); // clear everything
193
196
 
194
- // Conditional: removes only a *resolved successful* value matching the predicate.
197
+ // Conditional: awaits a pending entry, then removes a successful value matching the predicate.
195
198
  // Returns false for missing, expired, failed, or non-matching entries.
196
199
  const removed: boolean = yield* Cache.invalidateWhen(cache, 'k', (v) => v.stale);
197
200
  ```
@@ -205,7 +208,7 @@ const fresh = yield* Cache.refresh(cache, 'k');
205
208
  - Always invokes the lookup, even for an unexpired entry, and resets the TTL from the new exit.
206
209
  - For an **existing** key, the old entry keeps serving `get` callers until the new lookup completes — built-in stale-while-revalidate.
207
210
  - For a **missing** key, a pending entry is inserted immediately; concurrent `get`s wait on it.
208
- - Concurrent `refresh` calls are **not deduplicated** — each runs the lookup independently (only `get` dedups).
211
+ - `Cache.refresh` calls are **not deduplicated** — each runs the lookup independently. `ScopedCache.refresh` of a missing key delegates to `get` and shares that pending lookup; refreshes of existing keys run independently.
209
212
 
210
213
  ---
211
214
 
@@ -234,6 +237,11 @@ yield* Cache.get(cache, 'd'); // evicts 'b'
234
237
  - `Duration.infinity` (the default) means no expiry. `Duration.zero` means the entry expires immediately — effectively "do not cache this result".
235
238
  - `refresh` and re-`get`-after-expiry both restart the TTL clock; plain `get` hits do not extend it (no sliding expiration).
236
239
 
240
+ `Cache.get` does not retain a zero-TTL result after completion. A synchronous
241
+ zero-TTL lookup does not occupy capacity and evict a live entry; a pending lookup
242
+ still occupies capacity while it is shared. `ScopedCache` has the distinct lazy
243
+ resource-release behavior described in section 8.
244
+
237
245
  Per-key and per-result TTL via `makeWith`:
238
246
 
239
247
  ```ts
@@ -274,7 +282,7 @@ const robust = yield* Cache.makeWith(fetchUser, {
274
282
  });
275
283
  ```
276
284
 
277
- **Interruption poisons entries the same way**: the lookup runs on the fiber of the caller that triggered the miss. If that fiber is interrupted mid-lookup, the entry's deferred completes with the interrupt exit — concurrent waiters fail with it, and with an infinite TTL later `get`s keep replaying the interrupt instead of retrying. The exit-aware TTL above also fixes this, because an interrupt is a non-success exit (`Exit.isSuccess(exit) === false`) and gets a zero/short TTL.
285
+ **Interruption is not cached.** Missing-key lookups run in daemon fibers, shared by their waiters. One caller's interruption leaves the lookup alive while another caller is waiting. When the last waiter leaves before completion, the lookup is interrupted and its entry removed; a later `get` starts again. `ScopedCache` also closes that lookup's entry scope. Children forked with `forkChild` inside the lookup end with the lookup fiber, not with the requesting caller; use the entry scope for resource-lifetime background work.
278
286
 
279
287
  `getSuccess`, `values`, and `entries` skip failed entries; `getOption` and `get` propagate the cached error; `invalidateWhen` returns `false` for failed entries (the predicate only sees successes).
280
288
 
@@ -341,6 +349,10 @@ The entry's scope is closed — releasing everything the lookup acquired — whe
341
349
  4. **replaced** (`set` over an existing key; `refresh` closes the old entry's scope after the new lookup completes),
342
350
  5. **orphaned by cache close** (the owning scope closes).
343
351
 
352
+ A pending shared lookup also closes its entry scope when its last waiter leaves.
353
+ Interrupting an existing-key `refresh` closes the replacement scope and leaves
354
+ the previous entry intact.
355
+
344
356
  ```ts
345
357
  const tracker: Array<string> = [];
346
358
  const cache = yield* ScopedCache.make({
@@ -434,7 +446,7 @@ const cacheB = yield* Cache.make({
434
446
  const row = yield* Cache.get(cacheB, 1).pipe(Effect.provideService(Db, dbImpl));
435
447
  ```
436
448
 
437
- At runtime the lookup always sees the construction-time context merged with the caller's context (the caller's services win on conflicts). Tracing is connected: the lookup runs on the calling fiber, so spans created inside the lookup are children of the caller's current span. Remember the lookup runs **once per miss** — only the first caller's context matters for a given entry.
449
+ At runtime the lookup sees the construction-time context merged with the initiating caller's context (the caller's services win on conflicts). The shared lookup fiber inherits that caller's tracing context. Remember the lookup runs **once per miss** — later waiters do not replace the context for a pending entry.
438
450
 
439
451
  The same option exists on `ScopedCache.make`/`makeWith`.
440
452
 
@@ -597,9 +609,9 @@ it.effect('expires entries after the TTL', () =>
597
609
  2. **v4 renames type parameters, it does not reorder them** — v3's `Cache<Key, Value, Error>` becomes `Cache<Key, A, E, R>`: same value-before-error order with a services parameter appended. Only the pre-v3 `@effect/io` era used `Cache<Key, Error, Value>`.
598
610
  3. **`Cache.makeWith(lookup, options)` vs `ScopedCache.makeWith({ lookup, ...options })`** — `Cache.makeWith` takes the lookup as a separate first argument; `ScopedCache.makeWith` (and both `make`s) take one options object containing `lookup`.
599
611
  4. **Failures are cached with the default infinite TTL** — one transient lookup error fails that key forever. Use `makeWith` with an exit-aware TTL (`Exit.isSuccess(exit) ? ttl : Duration.zero`).
600
- 5. **Interrupting the fiber that started a lookup poisons the entry** — the lookup runs on the first caller's fiber; if it is interrupted, the interrupt exit is cached and replayed to waiters and later `get`s. The exit-aware TTL in (4) also covers interrupts.
612
+ 5. **Confusing caller cancellation with lookup cancellation** — a shared missing-key lookup survives while any waiter remains. The last departing waiter interrupts pending work; interrupted entries are removed, and `ScopedCache` closes their scopes. Failure TTL policy is still needed for ordinary failures.
601
613
  6. **`timeToLive` in `make` is a `Duration.Input` value, not a function** — the `(exit, key) => Duration.Input` form only exists on `makeWith`; passing a function to `make` won't compile.
602
- 7. **`refresh` is not deduplicated** — concurrent `refresh` calls each run the lookup; only `get` shares in-flight lookups. Serialize refreshes yourself if the lookup is expensive.
614
+ 7. **Assuming all refreshes deduplicate** — `Cache.refresh` and existing-key `ScopedCache.refresh` run independently. Only missing-key `ScopedCache.refresh` delegates to the shared `get` path. Serialize expensive refreshes if needed.
603
615
  8. **Don't mutate key objects after first use** — plain objects compare structurally in v4 (equal-content literals do hit), but `Equal`/`Hash` cache their results per object, so a mutated key misbehaves silently. Prefer primitives or immutable `Data.Class`/`Schema.Class` keys.
604
616
  9. **Don't `Effect.acquireRelease` inside a plain `Cache` lookup** — `Scope` leaks into `R` and finalizers attach to whatever outer scope is around, not to the entry: nothing is released on eviction/invalidation. Use `ScopedCache`, which provides a per-entry scope.
605
617
  10. **ScopedCache values die with their entry** — after `invalidate`/eviction/expiry-purge the value's finalizers have run. Don't hold the value beyond the entry's lifetime; use `effect/Pool` for checkout semantics.
@@ -20,8 +20,11 @@ scalars containing colon-whitespace or a trailing colon: quote those values.
20
20
 
21
21
  ## Import Pattern
22
22
 
23
+ The CLI APIs are marked `@stability unstable` even at the stable package release.
24
+ Inspect `effect@4.0.0:packages/effect/src/cli/` for this baseline's contracts.
25
+
23
26
  ```typescript
24
- import { Argument, Command, Flag, Prompt } from 'effect/unstable/cli';
27
+ import { Argument, Command, Flag, Prompt } from 'effect/cli';
25
28
  ```
26
29
 
27
30
  Platform services and runtime for the entry point:
@@ -37,7 +40,7 @@ Positional arguments are parsed in order. Use `Flag.Boolean` for toggles or `Arg
37
40
  ### Constructors
38
41
 
39
42
  ```typescript
40
- import { Argument } from 'effect/unstable/cli';
43
+ import { Argument } from 'effect/cli';
41
44
 
42
45
  Argument.String('name'); // string
43
46
  Argument.Int('count'); // number (integer)
@@ -64,7 +67,7 @@ Argument.FileSchema('config', MySchema); // reads and validates file via Schema
64
67
  ### Combinators
65
68
 
66
69
  ```typescript
67
- import { Argument } from 'effect/unstable/cli';
70
+ import { Argument } from 'effect/cli';
68
71
 
69
72
  // Description for help text
70
73
  Argument.String('file').pipe(Argument.withDescription('Input file'));
@@ -121,10 +124,16 @@ Argument.Int('count').pipe(
121
124
 
122
125
  Flags are named options with `--name` or `-alias` syntax.
123
126
 
127
+ Negative numeric tokens such as `-3`, `-3.70`, `-.5`, and `-1e-3` are values,
128
+ not short-option bundles: `--lon -3.70` works with `Flag.Finite('lon')`.
129
+ Use `--` to pass trailing operands that otherwise look like options; a lone
130
+ `-` is also retained as a value. Numeric lexing does not replace the chosen
131
+ primitive's validation.
132
+
124
133
  ### Constructors
125
134
 
126
135
  ```typescript
127
- import { Flag } from 'effect/unstable/cli';
136
+ import { Flag } from 'effect/cli';
128
137
 
129
138
  Flag.Boolean('verbose'); // required: --verbose / --no-verbose; omission fails
130
139
  Flag.String('config'); // --config value
@@ -152,7 +161,7 @@ Flag.KeyValuePair('env'); // --env FOO=bar → Record<string, string>
152
161
  ### Combinators
153
162
 
154
163
  ```typescript
155
- import { Flag } from 'effect/unstable/cli';
164
+ import { Flag } from 'effect/cli';
156
165
 
157
166
  // Alias
158
167
  Flag.Boolean('verbose').pipe(
@@ -217,7 +226,7 @@ Bare boolean flags are required. `--verbose` produces `true`, `--no-verbose` pro
217
226
  <!-- typecheck -->
218
227
  ```typescript
219
228
  import { Effect } from 'effect';
220
- import { Prompt } from 'effect/unstable/cli';
229
+ import { Prompt } from 'effect/cli';
221
230
 
222
231
  Prompt.Int({ message: 'Count', default: 42 });
223
232
  Prompt.File({ message: 'Pick file', default: '/workspace/config.json' });
@@ -253,7 +262,7 @@ choice values rather than pre-escaping your domain values.
253
262
 
254
263
  ```typescript
255
264
  import { Console, Effect } from 'effect';
256
- import { Argument, Command, Flag } from 'effect/unstable/cli';
265
+ import { Argument, Command, Flag } from 'effect/cli';
257
266
 
258
267
  // Simple command (no config, no handler)
259
268
  const version = Command.make('version');
@@ -509,7 +518,7 @@ Command.provideSync(MyService, (config) => makeMyService(config.env));
509
518
  ```typescript
510
519
  import { NodeRuntime, NodeServices } from '@effect/platform-node';
511
520
  import { Effect } from 'effect';
512
- import { Command, Flag } from 'effect/unstable/cli';
521
+ import { Command, Flag } from 'effect/cli';
513
522
 
514
523
  const myCommand = Command.make(
515
524
  'myapp',
@@ -5,13 +5,16 @@ description: Spawn and manage child processes using Effect's ChildProcess module
5
5
 
6
6
  # Child Process Execution with Effect v4
7
7
 
8
+ Targets **Effect 4.0.0**, checked against the `effect@4.0.0` source tag. Keep platform packages on the same version. The process API is marked `@stability unstable`, so check contracts again before a minor-version upgrade.
9
+
8
10
  ## Overview
9
11
 
10
- Node process-group cleanup waits for the leader and descendants. Without
12
+ On POSIX, Node process-group cleanup waits for the leader and descendants. Without
11
13
  `forceKillAfter`, cleanup waits up to one second without escalating. With it,
12
14
  the group receives SIGKILL at the deadline, then a final bounded wait. Native
13
15
  timers drive escalation even under TestClock. `exitCode` / `isRunning` describe
14
16
  the leader, not all descendants; do not use virtual time alone to prove OS cleanup.
17
+ On Windows, Node uses `taskkill` for tree termination and waits for the leader.
15
18
 
16
19
  The `ChildProcess` module provides type-safe, composable process execution with automatic resource cleanup via `Scope`. Commands are AST values — built first with `make` and `pipeTo`, then executed via the `ChildProcessSpawner` service.
17
20
 
@@ -23,12 +26,12 @@ The `ChildProcess` module provides type-safe, composable process execution with
23
26
  - Streaming output from long-running processes
24
27
  - Managing process lifecycles with scoped cleanup
25
28
 
26
- **Note:** This skill covers child process execution, NOT `@effect/cli` for building CLI applications.
29
+ **Note:** This skill covers child process execution; use `effect/cli` for building CLI applications.
27
30
 
28
31
  ## Import Pattern
29
32
 
30
33
  ```typescript
31
- import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
34
+ import { ChildProcess, ChildProcessSpawner } from 'effect/process';
32
35
  ```
33
36
 
34
37
  Platform layer (Node.js):
@@ -41,8 +44,9 @@ import { NodeServices } from '@effect/platform-node';
41
44
 
42
45
  ### Template Literal Form
43
46
 
47
+ <!-- typecheck -->
44
48
  ```typescript
45
- import { ChildProcess } from 'effect/unstable/process';
49
+ import { ChildProcess } from 'effect/process';
46
50
 
47
51
  // Simple command — parsed by whitespace
48
52
  const cmd = ChildProcess.make`echo hello world`;
@@ -58,8 +62,9 @@ const cmd3 = ChildProcess.make`oxfmt ${files}`;
58
62
 
59
63
  ### Options + Template Literal Form
60
64
 
65
+ <!-- typecheck -->
61
66
  ```typescript
62
- import { ChildProcess } from 'effect/unstable/process';
67
+ import { ChildProcess } from 'effect/process';
63
68
 
64
69
  // Options object returns a tagged template function
65
70
  const cmd = ChildProcess.make({ cwd: '/tmp' })`ls -la`;
@@ -73,8 +78,9 @@ const cmd2 = ChildProcess.make({
73
78
 
74
79
  ### Array Form
75
80
 
81
+ <!-- typecheck -->
76
82
  ```typescript
77
- import { ChildProcess } from 'effect/unstable/process';
83
+ import { ChildProcess } from 'effect/process';
78
84
 
79
85
  // Explicit command + args array
80
86
  const cmd = ChildProcess.make('git', ['status'], {
@@ -99,8 +105,9 @@ const cmd3 = ChildProcess.make('node', {
99
105
 
100
106
  ### Command Options
101
107
 
108
+ <!-- typecheck -->
102
109
  ```typescript
103
- import { ChildProcess } from 'effect/unstable/process';
110
+ import { ChildProcess } from 'effect/process';
104
111
 
105
112
  const cmd = ChildProcess.make('node', ['script.js'], {
106
113
  // Working directory
@@ -141,8 +148,9 @@ The `fd3`/`fd4` names configure child-process stdio channels. Process handles ex
141
148
 
142
149
  ### Combinators
143
150
 
151
+ <!-- typecheck -->
144
152
  ```typescript
145
- import { ChildProcess } from 'effect/unstable/process';
153
+ import { ChildProcess } from 'effect/process';
146
154
 
147
155
  // Set cwd (applies to all commands in a pipeline)
148
156
  const cmd = ChildProcess.make`ls -la`.pipe(ChildProcess.setCwd('/tmp'));
@@ -165,7 +173,7 @@ Commands are `Effect` values: `yield*` on a command evaluates through its `Effec
165
173
 
166
174
  ```typescript
167
175
  import { Effect } from 'effect';
168
- import { ChildProcessSpawner } from 'effect/unstable/process';
176
+ import { ChildProcessSpawner } from 'effect/process';
169
177
 
170
178
  const program = Effect.gen(function* () {
171
179
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -175,9 +183,10 @@ const program = Effect.gen(function* () {
175
183
 
176
184
  ### Capture as String
177
185
 
186
+ <!-- typecheck -->
178
187
  ```typescript
179
188
  import { Effect, String } from 'effect';
180
- import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
189
+ import { ChildProcess, ChildProcessSpawner } from 'effect/process';
181
190
 
182
191
  const program = Effect.gen(function* () {
183
192
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -190,39 +199,45 @@ const program = Effect.gen(function* () {
190
199
 
191
200
  ### Capture as Lines
192
201
 
202
+ <!-- typecheck -->
193
203
  ```typescript
194
204
  import { Effect } from 'effect';
195
- import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
205
+ import * as Arr from 'effect/Array';
206
+ import * as Str from 'effect/String';
207
+ import { ChildProcess, ChildProcessSpawner } from 'effect/process';
196
208
 
197
209
  const program = Effect.gen(function* () {
198
210
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
199
211
  const files = yield* spawner.lines(
200
212
  ChildProcess.make('git', ['diff', '--name-only', 'main...HEAD'])
201
213
  );
202
- const tsFiles = files.filter((f) => f.endsWith('.ts'));
214
+ const tsFiles = Arr.filter(files, Str.endsWith('.ts'));
203
215
  });
204
216
  ```
205
217
 
206
218
  ### Progressive Output with Side Effects
207
219
 
220
+ <!-- typecheck -->
208
221
  ```typescript
209
222
  import { Effect, Stream } from 'effect';
210
- import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
223
+ import { ChildProcess, ChildProcessSpawner } from 'effect/process';
211
224
 
212
225
  const program = Effect.gen(function* () {
213
226
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
214
- let output = '';
215
227
  const handle = yield* spawner.spawn(
216
228
  ChildProcess.make('bun', ['test'], {
217
229
  extendEnv: true,
218
230
  stdin: 'ignore'
219
231
  })
220
232
  );
221
- yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
222
- Effect.sync(() => {
223
- output += chunk;
224
- })
225
- ).pipe(Effect.forkScoped);
233
+ // Drain to EOF before leaving the scope, propagating read/progress failures.
234
+ const output = yield* handle.all.pipe(
235
+ Stream.decodeText(),
236
+ Stream.tap((chunk) => Effect.logDebug('Process output').pipe(
237
+ Effect.annotateLogs({ characters: chunk.length })
238
+ )),
239
+ Stream.mkString
240
+ );
226
241
  const exitCode = yield* handle.exitCode;
227
242
  return { output, exitCode };
228
243
  }).pipe(Effect.scoped);
@@ -230,29 +245,41 @@ const program = Effect.gen(function* () {
230
245
 
231
246
  Use this pattern when you need per-chunk side effects (progress reporting, streaming to UI) during command execution. `spawner.string`/`spawner.lines` cannot provide per-chunk callbacks.
232
247
 
248
+ Waiting for `handle.exitCode` alone does not drain output. If you fork a reader, join it before closing the scope and returning captured output. `Stream.mkString` buffers all text; for unbounded output, use `Stream.runForEach` without retaining it. Treat process output as potentially sensitive when choosing a progress callback.
249
+
233
250
  ### Get Exit Code
234
251
 
252
+ <!-- typecheck -->
235
253
  ```typescript
236
254
  import { Effect } from 'effect';
237
- import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
255
+ import * as Schema from 'effect/Schema';
256
+ import { ChildProcess, ChildProcessSpawner } from 'effect/process';
257
+
258
+ class CommandFailed extends Schema.TaggedError<CommandFailed>()('CommandFailed', {
259
+ exitCode: Schema.Number
260
+ }) {}
238
261
 
239
262
  const program = Effect.gen(function* () {
240
263
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
241
264
  const exitCode = yield* spawner.exitCode(
242
- ChildProcess.make('test', ['-f', 'package.json'])
265
+ ChildProcess.make('test', ['-f', 'package.json'], {
266
+ stdin: 'ignore', stdout: 'ignore', stderr: 'ignore'
267
+ })
243
268
  );
244
269
  // exitCode: ExitCode (branded number)
245
270
  if (exitCode !== ChildProcessSpawner.ExitCode(0)) {
246
- yield* Effect.fail(new Error('file not found'));
271
+ return yield* Effect.fail(new CommandFailed({ exitCode }));
247
272
  }
248
273
  });
249
274
  ```
250
275
 
276
+ `string`, `lines`, and stream helpers collect output; they do **not** reject a nonzero exit code. Use a scoped handle when success requires both captured output and a checked status. When using `exitCode` alone, inherit or ignore stdout/stderr so an unread pipe cannot fill and stall a noisy process. Output helpers default to stdout; pass `{ includeStderr: true }` to consume combined output, or configure stderr as `inherit`/`ignore` when only stdout is needed.
277
+
251
278
  ### Stream Output (Long-Running Processes)
252
279
 
253
280
  ```typescript
254
281
  import { Console, Effect, Stream } from 'effect';
255
- import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
282
+ import { ChildProcess, ChildProcessSpawner } from 'effect/process';
256
283
 
257
284
  const program = Effect.gen(function* () {
258
285
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -273,9 +300,15 @@ const program = Effect.gen(function* () {
273
300
 
274
301
  Use `spawner.spawn` when you need the process handle for interactive control, streaming output while running, or checking exit codes.
275
302
 
303
+ <!-- typecheck -->
276
304
  ```typescript
277
305
  import { Console, Effect, Stream } from 'effect';
278
- import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
306
+ import * as Schema from 'effect/Schema';
307
+ import { ChildProcess, ChildProcessSpawner } from 'effect/process';
308
+
309
+ class LintFailed extends Schema.TaggedError<LintFailed>()('LintFailed', {
310
+ exitCode: Schema.Number
311
+ }) {}
279
312
 
280
313
  const program = Effect.gen(function* () {
281
314
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -298,7 +331,7 @@ const program = Effect.gen(function* () {
298
331
  // Wait for exit
299
332
  const exitCode = yield* handle.exitCode;
300
333
  if (exitCode !== ChildProcessSpawner.ExitCode(0)) {
301
- yield* Effect.fail(new Error(`lint failed: exit ${exitCode}`));
334
+ return yield* Effect.fail(new LintFailed({ exitCode }));
302
335
  }
303
336
  }).pipe(Effect.scoped); // <-- spawn requires Scope
304
337
  ```
@@ -310,7 +343,7 @@ const program = Effect.gen(function* () {
310
343
  | `pid` | `ProcessId` | Process identifier (branded number) |
311
344
  | `exitCode` | `Effect<ExitCode, PlatformError>` | Waits for exit, returns exit code |
312
345
  | `isRunning` | `Effect<boolean, PlatformError>` | Check if still running |
313
- | `kill(options?)` | `Effect<void, PlatformError>` | Kill with signal; pass `{ forceKillAfter: '3 seconds' }` to ensure cleanup |
346
+ | `kill(options?)` | `Effect<void, PlatformError>` | Kill with signal; `forceKillAfter` bounds graceful waiting before escalation |
314
347
  | `stdin` | `Sink<void, Uint8Array, never, PlatformError>` | Write to process stdin |
315
348
  | `stdout` | `Stream<Uint8Array, PlatformError>` | Read process stdout |
316
349
  | `stderr` | `Stream<Uint8Array, PlatformError>` | Read process stderr |
@@ -320,9 +353,10 @@ const program = Effect.gen(function* () {
320
353
 
321
354
  ## Piping Commands
322
355
 
356
+ <!-- typecheck -->
323
357
  ```typescript
324
358
  import { Effect } from 'effect';
325
- import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
359
+ import { ChildProcess, ChildProcessSpawner } from 'effect/process';
326
360
 
327
361
  const program = Effect.gen(function* () {
328
362
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -371,12 +405,18 @@ const program = Effect.gen(function* () {
371
405
  }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
372
406
  ```
373
407
 
374
- Or provide just the spawner:
408
+ Or compose the spawner with its FileSystem and Path dependencies:
375
409
 
376
410
  ```typescript
377
- import { NodeChildProcessSpawner } from '@effect/platform-node';
411
+ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from '@effect/platform-node';
412
+ import { Effect, Layer } from 'effect';
413
+ import { ChildProcessSpawner } from 'effect/process';
378
414
 
379
- const program = myEffect.pipe(Effect.provide(NodeChildProcessSpawner.layer));
415
+ declare const myEffect: Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner>;
416
+ const SpawnerLayer = NodeChildProcessSpawner.layer.pipe(
417
+ Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
418
+ );
419
+ const program = myEffect.pipe(Effect.provide(SpawnerLayer));
380
420
  ```
381
421
 
382
422
  ## Complete Example: DevTools Service
@@ -392,7 +432,8 @@ import {
392
432
  Stream,
393
433
  String
394
434
  } from 'effect';
395
- import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
435
+ import * as Arr from 'effect/Array';
436
+ import { ChildProcess, ChildProcessSpawner } from 'effect/process';
396
437
 
397
438
  class DevToolsError extends Schema.TaggedError<DevToolsError>()(
398
439
  'DevToolsError',
@@ -442,7 +483,7 @@ class DevTools extends Context.Service<
442
483
  .pipe(
443
484
  Effect.mapError((cause) => new DevToolsError({ cause }))
444
485
  );
445
- return files.filter((file) => file.endsWith('.ts'));
486
+ return Arr.filter(files, String.endsWith('.ts'));
446
487
  });
447
488
 
448
489
  const recentCommitSubjects = spawner
@@ -463,7 +504,7 @@ class DevTools extends Context.Service<
463
504
  const runLintFix = Effect.gen(function* () {
464
505
  const handle = yield* spawner
465
506
  .spawn(
466
- ChildProcess.make('bun', ['lint'], {
507
+ ChildProcess.make('bun', ['lint', '--fix'], {
467
508
  env: { FORCE_COLOR: '1' },
468
509
  extendEnv: true,
469
510
  stdin: 'ignore'
@@ -484,11 +525,9 @@ class DevTools extends Context.Service<
484
525
  Effect.mapError((cause) => new DevToolsError({ cause }))
485
526
  );
486
527
  if (exitCode !== ChildProcessSpawner.ExitCode(0)) {
487
- return yield* new DevToolsError({
488
- cause: new Error(
489
- `bun lint failed with exit code ${exitCode}`
490
- )
491
- });
528
+ return yield* Effect.fail(new DevToolsError({
529
+ cause: { command: 'bun lint --fix', exitCode }
530
+ }));
492
531
  }
493
532
  }).pipe(Effect.scoped);
494
533
 
@@ -499,7 +538,9 @@ class DevTools extends Context.Service<
499
538
  runLintFix
500
539
  });
501
540
  })
502
- ).pipe(Layer.provide(NodeServices.layer));
541
+ );
542
+
543
+ static readonly defaultLayer = DevTools.layer.pipe(Layer.provide(NodeServices.layer));
503
544
  }
504
545
  ```
505
546
 
@@ -516,14 +557,14 @@ const program = Effect.gen(function* () {
516
557
  }).pipe(Effect.scoped);
517
558
  ```
518
559
 
519
- ### DON'T: Use `spawner.spawn` without scoping
560
+ ### DON'T: Run a scoped program without providing its Scope
520
561
 
521
562
  ```typescript
522
- // Process handle leaks — no scope to manage cleanup
563
+ // This definition is valid and retains Scope in its requirements.
523
564
  const program = Effect.gen(function* () {
524
565
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
525
566
  const handle = yield* spawner.spawn(ChildProcess.make`my-server`);
526
- // ❌ no Effect.scoped — process may leak
567
+ // The caller must own/provide Scope before running this program.
527
568
  });
528
569
  ```
529
570
 
@@ -571,22 +612,15 @@ class MyService extends Context.Service<
571
612
  >()('MyService') {}
572
613
  ```
573
614
 
574
- ### DO: Check `ExitCode` with the branded constructor
615
+ ### DO: Check the exit status explicitly
575
616
 
576
617
  ```typescript
577
618
  if (exitCode !== ChildProcessSpawner.ExitCode(0)) {
578
- yield* Effect.fail(new Error(`command failed: ${exitCode}`));
619
+ return yield* Effect.fail(new CommandFailed({ exitCode }));
579
620
  }
580
621
  ```
581
622
 
582
- ### DON'T: Compare exit codes as raw numbers
583
-
584
- ```typescript
585
- // ❌ ExitCode is a branded type — direct number comparison may not work as expected
586
- if (exitCode !== 0) {
587
- /* ... */
588
- }
589
- ```
623
+ `ExitCode` is a branded **number**, not a boxed runtime value. Both `exitCode !== 0` and comparison with `ChildProcessSpawner.ExitCode(0)` work. The constructor is useful when supplying an ExitCode to a typed API; the brand does not change numeric comparison semantics.
590
624
 
591
625
  ### DO: Use `handle.all` for interleaved stdout+stderr
592
626
 
@@ -610,9 +644,10 @@ yield* Stream.merge(handle.stdout, handle.all).pipe(Stream.runCollect);
610
644
 
611
645
  Child process operations fail with `PlatformError`:
612
646
 
647
+ <!-- typecheck -->
613
648
  ```typescript
614
649
  import { Effect } from 'effect';
615
- import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process';
650
+ import { ChildProcess, ChildProcessSpawner } from 'effect/process';
616
651
 
617
652
  const program = Effect.gen(function* () {
618
653
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -621,7 +656,9 @@ const program = Effect.gen(function* () {
621
656
  .string(ChildProcess.make`non-existent-command`)
622
657
  .pipe(
623
658
  Effect.catchTag('PlatformError', (error) =>
624
- Effect.succeed(`fallback: ${error.message}`)
659
+ error.reason._tag === 'NotFound'
660
+ ? Effect.succeed('Optional command is unavailable')
661
+ : Effect.fail(error)
625
662
  )
626
663
  );
627
664
  });
@@ -637,6 +674,7 @@ Keep process kill, timeout, and output-drain logic inside the process adapter th
637
674
 
638
675
  Use `Effect.callback` to convert an `AbortSignal` (or any event-driven API) into an Effect. The cleanup Effect removes the listener, and the already-aborted edge case is handled first:
639
676
 
677
+ <!-- typecheck -->
640
678
  ```typescript
641
679
  import { Effect } from 'effect';
642
680
 
@@ -651,16 +689,25 @@ const fromAbortSignal = (signal: AbortSignal) =>
651
689
 
652
690
  ### Abort / Timeout Multiplexing
653
691
 
654
- Combine `Effect.raceAll` with discriminated result types to handle exit, abort, and timeout in a single expression:
692
+ Combine `Effect.raceAll` with discriminated result types to handle successful exit, abort, and timeout in a single expression. `raceAll` waits for the first success, so turn exit observation into an `Exit` value to preserve a failed exit-code read rather than hiding it behind the timeout:
655
693
 
694
+ <!-- typecheck -->
656
695
  ```typescript
657
- import { Effect } from 'effect';
658
-
659
- const exit =
660
- yield*
661
- Effect.raceAll([
696
+ import { Duration, Effect } from 'effect';
697
+ import { ChildProcessSpawner } from 'effect/process';
698
+
699
+ declare const fromAbortSignal: (signal: AbortSignal) => Effect.Effect<void>;
700
+
701
+ // Call within the scope that owns the handle and its output reader.
702
+ const awaitOutcome = Effect.fn('Process.awaitOutcome')(function* (
703
+ handle: ChildProcessSpawner.ChildProcessHandle,
704
+ signal: AbortSignal,
705
+ timeout: Duration.Input
706
+ ) {
707
+ const exit = yield* Effect.raceAll([
662
708
  handle.exitCode.pipe(
663
- Effect.map((code) => ({ kind: 'exit' as const, code }))
709
+ Effect.exit,
710
+ Effect.map((result) => ({ kind: 'exit' as const, result }))
664
711
  ),
665
712
  fromAbortSignal(signal).pipe(
666
713
  Effect.map(() => ({ kind: 'abort' as const }))
@@ -669,11 +716,15 @@ const exit =
669
716
  Effect.map(() => ({ kind: 'timeout' as const }))
670
717
  )
671
718
  ]);
672
- if (exit.kind !== 'exit') {
673
- yield* handle.kill({ forceKillAfter: '3 seconds' });
674
- }
719
+ if (exit.kind !== 'exit') {
720
+ yield* handle.kill({ forceKillAfter: '3 seconds' });
721
+ }
722
+ return exit;
723
+ });
675
724
  ```
676
725
 
726
+ Handle `exit.result` with `Exit.match` when `kind === 'exit'`; it includes observation failures. This race only chooses the control outcome: drain/join any output reader separately within the owning scope before returning captured output.
727
+
677
728
  ## Related Skills
678
729
 
679
730
  - **effect-platform-abstraction**: FileSystem, Path, and other platform services