@drkmttr/adhere 0.6.0 → 0.7.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 (37) hide show
  1. package/README.md +75 -39
  2. package/package.json +7 -7
  3. package/presets/effect/basics/external-calls-are-resilient.md +6 -2
  4. package/presets/effect/basics/fn-for-named-effects.md +10 -2
  5. package/presets/effect/basics/gen-for-sequencing.md +10 -2
  6. package/presets/effect/basics/instrument-with-pipe.md +9 -2
  7. package/presets/effect/cli/handlers-delegate.md +13 -2
  8. package/presets/effect/config/business-logic-depends-on-config-service.md +9 -2
  9. package/presets/effect/config/secrets-are-redacted.md +6 -2
  10. package/presets/effect/config/tests-provide-values-directly.md +8 -2
  11. package/presets/effect/config/validate-with-schema.md +9 -2
  12. package/presets/effect/data/brand-meaningful-primitives.md +11 -2
  13. package/presets/effect/data/decode-json-with-schema.md +6 -2
  14. package/presets/effect/data/records-are-schema-classes.md +9 -2
  15. package/presets/effect/data/variants-are-tagged-unions.md +9 -2
  16. package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
  17. package/presets/effect/errors/defects-for-bugs.md +13 -2
  18. package/presets/effect/errors/domain-errors-are-tagged.md +6 -2
  19. package/presets/effect/errors/wrap-external-errors.md +7 -2
  20. package/presets/effect/platform/os-access-through-platform-services.md +8 -2
  21. package/presets/effect/services/fresh-layer-per-test.md +9 -2
  22. package/presets/effect/services/memoize-parameterized-layers.md +9 -2
  23. package/presets/effect/services/methods-have-no-requirements.md +9 -2
  24. package/presets/effect/services/no-mutable-state.md +9 -2
  25. package/presets/effect/services/provide-at-entry.md +7 -2
  26. package/presets/effect/services/test-layers-are-in-memory.md +12 -2
  27. package/presets/effect/testing/test-clock-for-time.md +11 -2
  28. package/presets/effect/testing/test-random.md +9 -2
  29. package/skills/adhere/SKILL.md +24 -17
  30. package/src/cli.ts +2 -2
  31. package/src/config.ts +67 -13
  32. package/src/init.ts +10 -8
  33. package/src/markdown.ts +59 -25
  34. package/src/services/Jev.http.ts +36 -11
  35. package/src/services/Jev.ts +75 -36
  36. package/src/workflows/audit.ts +34 -21
  37. package/src/workflows/format.ts +7 -3
package/README.md CHANGED
@@ -1,26 +1,32 @@
1
1
  # adhere
2
2
 
3
3
  A linter for rules a normal linter cannot check. A rule is a one-sentence
4
- description plus correct reference code, code to avoid, or both. For each
5
- source file, adhere asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
4
+ description in RFC 2119's words, like "business logic must live in services",
5
+ with code that must be written that way, code that must never be, or both. For
6
+ each source file, adhere asks [Jev](https://typesafe.ai) (TypeSafe AI's System One
6
7
  model) whether the file breaks each rule, gets a calibrated probability per
7
8
  rule, and reports the ones above a threshold with the line Jev points at. The
8
9
  report uses the same frame as `vp lint`.
9
10
 
10
11
  Deterministic rules (substring matches, type checks) belong in a normal linter.
11
12
 
13
+ A preset for effect is included which can be run without setup:
14
+
15
+ ```sh
16
+ TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset effect
17
+ ```
18
+
12
19
  ## Install
13
20
 
14
21
  ```sh
15
- npm install --global @drkmttr/adhere # or: bun add --global @drkmttr/adhere
22
+ bun add --global @drkmttr/adhere # or: npm install --global @drkmttr/adhere
16
23
  ```
17
24
 
18
25
  To pin the version in a repo, for CI or scripts, add it as a dev dependency
19
- and run `npx adhere`. To try it without installing, run
20
- `npx @drkmttr/adhere` or `bunx @drkmttr/adhere`.
26
+ and run `npx adhere`.
21
27
 
22
28
  `adhere` is a prebuilt executable for macOS and Linux on arm64 and x64 and for
23
- Windows on x64. It needs Node to start, and no Bun.
29
+ Windows on x64. .
24
30
 
25
31
  adhere sends each file it judges to Jev at `api.typesafe.ai`, authenticated
26
32
  with a TypeSafe AI API key: the one `adhere login` saved, or
@@ -32,7 +38,7 @@ with a TypeSafe AI API key: the one `adhere login` saved, or
32
38
  adhere init # .adhere/config.ts and two example rules
33
39
  echo ".adhere/cache/" >> .gitignore
34
40
  adhere login # save your TypeSafe AI API key, once
35
- adhere validate # check the rules, and ask Jev whether any contradict
41
+ adhere validate # Checks your rules for contradictions using Jev
36
42
  adhere lint # audit the working directory
37
43
  ```
38
44
 
@@ -55,8 +61,8 @@ Found 1 error.
55
61
  ```
56
62
 
57
63
  The header is the rule id, Jev's probability, and the rule's description. The
58
- hint is the rule's reference; a rule with only code to avoid shows that code,
59
- labeled `avoid:`, instead. On a terminal, the report is in color and the code
64
+ hint is the rule's code that must be written; a rule with only code that must
65
+ never be written shows that code, labeled `never:`, instead. On a terminal, the report is in color and the code
60
66
  in it is highlighted. The exit code is 0 when nothing is reported and 1 when
61
67
  something is. It is also 1 when the run refuses, for example on an invalid
62
68
  config or rule file, a missing API key, or an unknown command or flag; a
@@ -67,7 +73,7 @@ refusal prints its reason.
67
73
  ```sh
68
74
  adhere lint # audit the working directory
69
75
  adhere lint --preset effect # add a built-in rule set; the config becomes optional
70
- adhere lint --threshold 0.8 # replace the config's threshold; per-rule thresholds still apply
76
+ adhere lint --threshold 0.9 # replace the config's threshold; per-rule thresholds still apply
71
77
  adhere lint --yes # send the requests without asking first
72
78
  adhere validate # load the config and rules, ask Jev whether any contradict
73
79
  adhere init [--force] # scaffold .adhere/config.ts and two example rules
@@ -155,17 +161,17 @@ import type { Config } from "@drkmttr/adhere";
155
161
 
156
162
  export default {
157
163
  model: "jev-latest", // optional, default "jev-latest"
158
- threshold: 0.7, // optional, default 0.7
164
+ threshold: 0.8, // optional, default 0.8
159
165
  presets: ["effect"], // optional, built-in rule sets
160
166
  rules: {
161
167
  "data/brand-meaningful-primitives": {
162
168
  description:
163
- "A primitive with semantic meaning, such as an id, email, URL, port, or count, should be a branded schema.",
164
- reference: `
169
+ "A primitive with semantic meaning, such as an id, email, URL, port, or count, must be a branded schema.",
170
+ must: `
165
171
  const UserId = Schema.String.pipe(Schema.brand("UserId"))
166
172
  type UserId = typeof UserId.Type
167
173
  `,
168
- avoid: "type UserId = string", // optional: what a violation looks like
174
+ never: "type UserId = string", // optional: what a violation looks like
169
175
  threshold: 0.8, // optional per-rule override
170
176
  },
171
177
  },
@@ -190,44 +196,54 @@ config and the cache (neither is read as a rule). The path without `.md` is the
190
196
  rule id, so `.adhere/data/brand-ports.md` is `data/brand-ports`. When that
191
197
  directory exists, it is read without any config.
192
198
 
193
- A file is front matter, then a body. The first fenced code block in the body
194
- is the reference; prose around it renders on GitHub and is ignored. Without a
195
- fence, the whole body is the reference. A fence tagged `avoid` after its
196
- language holds code to avoid instead: what a violation looks like.
199
+ A file is front matter, then a body. The body's code goes in fences tagged,
200
+ after the language, with RFC 2119's words: `must` for code that must be
201
+ written, and `never` for code that must never be, which is what a violation
202
+ looks like. Prose around them renders on GitHub and is ignored. An untagged
203
+ fence is code that must be written, and without a fence the whole body is.
204
+ `avoid`, the tag before 0.7, reads as `never`.
197
205
 
198
206
  ````md
199
207
  ---
200
- description: A port is a branded, range-checked integer, not a bare number.
208
+ description: A port must be a branded, range-checked integer, never a bare number.
201
209
  threshold: 0.8
202
210
  ---
203
211
 
204
212
  Why: a bare `number` accepts 70000 and -1.
205
213
 
206
- ```ts
214
+ ```ts must
207
215
  const Port = Schema.Int.pipe(
208
216
  Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
209
217
  Schema.brand("Port"),
210
218
  );
211
219
  ```
212
220
 
213
- Not this:
214
-
215
- ```ts avoid
221
+ ```ts never
216
222
  const port: number = Number(process.env.PORT);
217
223
  ```
218
224
  ````
219
225
 
220
- `description` is required, with a reference, code to avoid, or both.
226
+ `description` is required, with code under `must`, `never`, or both.
221
227
  `threshold` is optional. A file that fails validation refuses the run with its
222
228
  path in the message. `loadRules(directory)` from the package root does the same
223
229
  load for your own tooling.
224
230
 
225
- With a reference, Jev is asked whether the file diverges from it, and code to
226
- avoid, when there is some, is its example of diverging. With only code to
227
- avoid, Jev is asked whether the file contains it. That suits a rule with no
228
- single correct form to show, such as a hand-rolled retry loop or an error
229
- caught and dropped. Neither goes in the other's place: code to avoid in the
230
- reference reads to Jev as the pattern to follow.
231
+ Jev reads the code under the same words, and is asked whether the file
232
+ diverges from the pattern `must` shows, with `never` as an example of
233
+ diverging. Write the description in them too, so the rule and its code agree:
234
+ "must" and "never", as above.
235
+
236
+ A rule with only code that must never be written suits a rule with no single
237
+ correct form to show, such as a hand-rolled retry loop or an error caught and
238
+ dropped. Neither goes in the other's place: code that must never be written,
239
+ under `must`, reads to Jev as the pattern to follow.
240
+
241
+ A rule that is a guideline rather than a requirement says "should" instead:
242
+ in its description, and in fences tagged `should` and `should not` (`should`
243
+ and `shouldNot` inline in a config). Jev then reads the code under `should`
244
+ and `should_not`. A rule is one or the other, so it cannot mix `must` or
245
+ `never` with `should` or `should not`. In a config, `reference` and `avoid`,
246
+ the names before 0.7, read as `must` and `never`.
231
247
 
232
248
  Nested `.adhere/` directories are also discovered, except under
233
249
  `node_modules/`, `dist/`, and the other skipped directories (above). A rule in
@@ -254,6 +270,22 @@ export default { presets: ["effect"], rules: "./docs/adhere" } satisfies Config;
254
270
 
255
271
  Rules read through `rules` apply project-wide.
256
272
 
273
+ ### Rule writing tips
274
+
275
+ We've evaluated different ways of giving Jev a rule, to catch the most
276
+ violations with the fewest false positives. In general:
277
+
278
+ - Say "must" for what code must do and "never" for what it must not, in the
279
+ description and as the fence tags.
280
+ - Give one example of each: one `must` block and one `never` block. Three of
281
+ each did no better, and several of one kind alone did worse.
282
+ - For a guideline rather than a requirement, say "should" and "should not"
283
+ the same way.
284
+
285
+ On the Effect preset, rules written this way cut the findings Jev got wrong at
286
+ the default threshold from 6 to 1, and caught as many violations. For more
287
+ about the evaluations, see [the eval](eval/README.md).
288
+
257
289
  ### Presets
258
290
 
259
291
  A preset has the shape of a config without `presets`: `rules`, and optionally
@@ -267,7 +299,7 @@ case the config file is optional.
267
299
  ### Precedence
268
300
 
269
301
  Highest first: `--threshold` on the command line, the config file, presets in
270
- order (a later preset wins), then the defaults `jev-latest` and `0.7`. A rule
302
+ order (a later preset wins), then the defaults `jev-latest` and `0.8`. A rule
271
303
  in `rules` replaces a preset rule with the same id. A rule's own `threshold`
272
304
  beats all of the above for that rule.
273
305
 
@@ -275,13 +307,15 @@ beats all of the above for that rule.
275
307
 
276
308
  1. One Jev request per file. The state is the file's numbered lines and
277
309
  nothing else. Each rule is one `noul` (yes/no probability) question that
278
- carries the rule: whether the file breaks the rule's description, with
279
- criteria for each answer. Yes means part of the file breaks the rule, and
280
- the code to avoid is an example of it. No means the file follows the rule
281
- or has nothing the rule covers, and the reference is an example of it.
282
- Since no rule sits in the shared state, a rule's probability depends only
283
- on the file and that rule, not on which other rules share the request.
284
- Every question shares the state cost of the request.
310
+ carries the rule's description and its code under its words, `must` and
311
+ `never` (or a guideline's `should` and `should_not`): whether the file
312
+ diverges from the pattern `must` shows, with `never` as an example of
313
+ diverging, or, for a rule with only code that must never be written,
314
+ whether the file contains that code. Criteria for each answer draw the line
315
+ at the rule's scope, so a file with no code the rule is about is a no. Since
316
+ no rule sits in the shared state, a rule's probability depends only on the
317
+ file and that rule, not on which other rules share the request. Every
318
+ question shares the state cost of the request.
285
319
  2. A second request only when at least one rule's probability is above its
286
320
  threshold: one `choice` question per flagged rule over the file's non-blank
287
321
  lines, which yields the line to report. A file with more than 255 non-blank
@@ -291,7 +325,9 @@ beats all of the above for that rule.
291
325
  about three bytes a token. When a file's questions would not fit in one
292
326
  request, they are split across several. A file whose code and longest
293
327
  question would not fit together, or that has more than 5100 lines, is
294
- skipped and counted in the summary, and the rest of the run goes on.
328
+ skipped and counted in the summary, and the rest of the run goes on. So is
329
+ a file Jev itself counts as over its context, which text denser than the
330
+ estimate, such as CJK, can cause.
295
331
 
296
332
  ## Cache
297
333
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
- "description": "Lints a repository against reference code in .adhere/, judged by Jev.",
5
+ "description": "Lints a repository against the rules in .adhere/, judged by Jev.",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/darkmatter/adhere.git"
@@ -67,10 +67,10 @@
67
67
  }
68
68
  },
69
69
  "optionalDependencies": {
70
- "@drkmttr/adhere-darwin-arm64": "0.6.0",
71
- "@drkmttr/adhere-darwin-x64": "0.6.0",
72
- "@drkmttr/adhere-linux-arm64": "0.6.0",
73
- "@drkmttr/adhere-linux-x64": "0.6.0",
74
- "@drkmttr/adhere-win32-x64": "0.6.0"
70
+ "@drkmttr/adhere-darwin-arm64": "0.7.0",
71
+ "@drkmttr/adhere-darwin-x64": "0.7.0",
72
+ "@drkmttr/adhere-linux-arm64": "0.7.0",
73
+ "@drkmttr/adhere-linux-x64": "0.7.0",
74
+ "@drkmttr/adhere-win32-x64": "0.7.0"
75
75
  }
76
76
  }
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A call over the network, such as an HTTP request, a database query, or a third-party API, carries a timeout and a retry schedule. Calls through the platform FileSystem and Path services are local and are not in scope.
2
+ description: A call over the network, such as an HTTP request, a database query, or a third-party API, must carry a timeout and a retry schedule. Calls through the platform FileSystem and Path services are local and are not in scope.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  const retryPolicy = Schedule.exponential("100 millis").pipe(Schedule.both(Schedule.recurs(3)));
7
7
 
8
8
  const resilientCall = HttpClient.get("https://api.example.com/users").pipe(
@@ -11,3 +11,7 @@ const resilientCall = HttpClient.get("https://api.example.com/users").pipe(
11
11
  Effect.timeout("10 seconds"),
12
12
  );
13
13
  ```
14
+
15
+ ```ts never
16
+ const response = yield* HttpClient.get(`https://api.example.com/users/${id}`);
17
+ ```
@@ -1,11 +1,19 @@
1
1
  ---
2
- description: A named function that returns an Effect should be defined with Effect.fn so the call site is traced.
2
+ description: A named function that returns an Effect must be defined with Effect.fn so the call site is traced. An Effect held in a constant, such as `const program = Effect.gen(...)`, is not a function and is not in scope.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  const processUser = Effect.fn("processUser")(function* (userId: string) {
7
7
  yield* Effect.logInfo(`Processing user ${userId}`);
8
8
  const user = yield* getUser(userId);
9
9
  return yield* processData(user);
10
10
  });
11
11
  ```
12
+
13
+ ```ts never
14
+ const loadUser = (id: string) =>
15
+ Effect.gen(function* () {
16
+ const repo = yield* UserRepo;
17
+ return yield* repo.find(id);
18
+ });
19
+ ```
@@ -1,11 +1,19 @@
1
1
  ---
2
- description: Sequential effectful steps should be written with Effect.gen and yield*, not nested flatMap or callback chains.
2
+ description: Sequential effectful steps must be written with Effect.gen and yield*, never as nested flatMap or callback chains.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  const program = Effect.gen(function* () {
7
7
  const data = yield* fetchData;
8
8
  yield* Effect.logInfo(`Processing data: ${data}`);
9
9
  return yield* processData(data);
10
10
  });
11
11
  ```
12
+
13
+ ```ts never
14
+ const page = fetchUser(id).pipe(
15
+ Effect.flatMap((user) =>
16
+ fetchOrders(user.id).pipe(Effect.flatMap((orders) => render(user, orders))),
17
+ ),
18
+ );
19
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: Timeouts, retries, logging, and spans should be attached with .pipe, not written into the body of the effect.
2
+ description: Timeouts, retries, logging, and spans must be attached with .pipe, never written into the body of the effect.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  const program = fetchData.pipe(
7
7
  Effect.timeout("5 seconds"),
8
8
  Effect.retry(Schedule.exponential("100 millis").pipe(Schedule.both(Schedule.recurs(3)))),
@@ -10,3 +10,10 @@ const program = fetchData.pipe(
10
10
  Effect.withSpan("fetchData"),
11
11
  );
12
12
  ```
13
+
14
+ ```ts never
15
+ const report = Effect.gen(function* () {
16
+ const rows = yield* Effect.timeout(loadRows, "5 seconds");
17
+ return yield* Effect.withSpan(summarize(rows), "summarize");
18
+ });
19
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A command handler parses input and calls a service. Business logic lives in the service.
2
+ description: A command handler must only parse input and call a service. Business logic must live in the service, never in the handler.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  const addCommand = Command.make("add", { text }, ({ text }) =>
7
7
  Effect.gen(function* () {
8
8
  const repo = yield* TaskRepo;
@@ -11,3 +11,14 @@ const addCommand = Command.make("add", { text }, ({ text }) =>
11
11
  }),
12
12
  ).pipe(Command.withDescription("Add a new task"));
13
13
  ```
14
+
15
+ ```ts never
16
+ Command.make("rename", { from, to }, ({ from, to }) =>
17
+ Effect.gen(function* () {
18
+ const fs = yield* FileSystem.FileSystem;
19
+ const text = yield* fs.readFileString(from);
20
+ yield* fs.writeFileString(to, text.replaceAll("draft", "final"));
21
+ yield* fs.remove(from);
22
+ }),
23
+ );
24
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: Business logic depends on a config service. Config primitives should be read only inside that service's layer.
2
+ description: Business logic must depend on a config service. Config primitives must be read only inside that service's layer, never in business logic.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  class ApiConfig extends Context.Service<
7
7
  ApiConfig,
8
8
  {
@@ -20,3 +20,10 @@ class ApiConfig extends Context.Service<
20
20
  );
21
21
  }
22
22
  ```
23
+
24
+ ```ts never
25
+ const sendInvoice = Effect.fn("sendInvoice")(function* (invoice: Invoice) {
26
+ const apiUrl = yield* Config.string("BILLING_API_URL");
27
+ return yield* post(apiUrl, invoice);
28
+ });
29
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A token, password, or key should be read with Config.redacted.
2
+ description: A token, password, or key must be read with Config.redacted, never with Config.string.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  const program = Effect.gen(function* () {
7
7
  const apiKey = yield* Config.redacted("API_KEY");
8
8
 
@@ -13,3 +13,7 @@ const program = Effect.gen(function* () {
13
13
  return headers;
14
14
  });
15
15
  ```
16
+
17
+ ```ts never
18
+ const token = yield* Config.string("GITHUB_TOKEN");
19
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A test supplies config with Layer.succeed on the config service, not by setting environment variables or a ConfigProvider.
2
+ description: A test must supply config with Layer.succeed on the config service, never by setting environment variables or a ConfigProvider.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  Effect.runPromise(
7
7
  program.pipe(
8
8
  Effect.provide(
@@ -14,3 +14,9 @@ Effect.runPromise(
14
14
  ),
15
15
  );
16
16
  ```
17
+
18
+ ```ts never
19
+ beforeAll(() => {
20
+ process.env.DATABASE_URL = "postgres://localhost/test";
21
+ });
22
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A config value with constraints should be read with Config.schema, not mapped and checked by hand.
2
+ description: A config value with constraints must be read with Config.schema, never mapped and checked by hand.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  const Port = Schema.NumberFromString.pipe(
7
7
  Schema.check(Schema.isInt()),
8
8
  Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
@@ -16,3 +16,10 @@ const program = Effect.gen(function* () {
16
16
  return { port, env };
17
17
  });
18
18
  ```
19
+
20
+ ```ts never
21
+ const timeoutMs = yield* Config.number("TIMEOUT_MS");
22
+ if (timeoutMs <= 0 || timeoutMs > 60_000) {
23
+ return yield* Effect.die("TIMEOUT_MS out of range");
24
+ }
25
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A primitive with semantic meaning, such as an id, email, URL, port, or count, should be a branded schema.
2
+ description: A primitive with semantic meaning, such as an id, email, URL, port, or count, must be a branded schema, never a bare string or number.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  export const UserId = Schema.String.pipe(Schema.brand("UserId"));
7
7
  export type UserId = typeof UserId.Type;
8
8
 
@@ -15,3 +15,12 @@ export const Port = Schema.Int.pipe(
15
15
  );
16
16
  export type Port = typeof Port.Type;
17
17
  ```
18
+
19
+ ```ts never
20
+ type AccountId = string;
21
+
22
+ const Session = Schema.Struct({
23
+ accountId: Schema.String,
24
+ retries: Schema.Number,
25
+ });
26
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: JSON crossing a boundary should be decoded with Schema.fromJsonString, not JSON.parse followed by a cast.
2
+ description: JSON crossing a boundary must be decoded with Schema.fromJsonString, never with JSON.parse followed by a cast.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  const MoveFromJson = Schema.fromJsonString(Move);
7
7
 
8
8
  const program = Effect.gen(function* () {
@@ -12,3 +12,7 @@ const program = Effect.gen(function* () {
12
12
  return json;
13
13
  });
14
14
  ```
15
+
16
+ ```ts never
17
+ const settings = JSON.parse(text) as Settings;
18
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: Domain records should be defined with Schema.Class, not with a plain interface or type alias.
2
+ description: Domain records must be defined with Schema.Class, never with a plain interface or type alias.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  export class User extends Schema.Class<User>("User")({
7
7
  id: UserId,
8
8
  name: Schema.String,
@@ -14,3 +14,10 @@ export class User extends Schema.Class<User>("User")({
14
14
  }
15
15
  }
16
16
  ```
17
+
18
+ ```ts never
19
+ interface Invoice {
20
+ readonly id: InvoiceId;
21
+ readonly total: Money;
22
+ }
23
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: Structured variants should be Schema.TaggedClass in a Schema.Union and matched with Match.tag and Match.exhaustive.
2
+ description: Structured variants must be Schema.TaggedClass in a Schema.Union, matched with Match.tag and Match.exhaustive, never a plain union checked with a switch.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  export class Success extends Schema.TaggedClass<Success>("Success")("Success", {
7
7
  value: Schema.Number,
8
8
  }) {}
@@ -21,3 +21,10 @@ const renderResult = (result: Result) =>
21
21
  Match.exhaustive,
22
22
  );
23
23
  ```
24
+
25
+ ```ts never
26
+ type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
27
+
28
+ const area = (shape: Shape) =>
29
+ shape.kind === "circle" ? Math.PI * shape.radius ** 2 : shape.side ** 2;
30
+ ```
@@ -1,11 +1,15 @@
1
1
  ---
2
- description: Defects should be caught only at a system boundary for logging or shutdown, never in business logic.
2
+ description: Defects must be caught only at a system boundary for logging or shutdown, never in business logic.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  // At app entry: if config fails, nothing can proceed
7
7
  const main = Effect.gen(function* () {
8
8
  const config = yield* loadConfig.pipe(Effect.orDie);
9
9
  yield* Effect.log(`Starting on port ${config.port}`);
10
10
  });
11
11
  ```
12
+
13
+ ```ts never
14
+ const price = computePrice(cart).pipe(Effect.catchDefect(() => Effect.succeed(0)));
15
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A typed error should be for a failure the caller can handle. A bug or invariant violation should be a defect.
2
+ description: A typed error must be for a failure the caller can handle. A bug or invariant violation must be a defect, never a typed error.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  const recovered: Effect.Effect<string, ValidationError> = program.pipe(
7
7
  Effect.catchTag("HttpError", (error) =>
8
8
  Effect.gen(function* () {
@@ -17,3 +17,14 @@ const main = Effect.gen(function* () {
17
17
  yield* Effect.log(`Starting on port ${config.port}`);
18
18
  });
19
19
  ```
20
+
21
+ ```ts never
22
+ class CacheCorrupted extends Schema.TaggedError<CacheCorrupted>()("CacheCorrupted", {
23
+ key: Schema.String,
24
+ }) {}
25
+
26
+ const entry = yield* cache.get(key);
27
+ if (entry !== undefined && entry.key !== key) {
28
+ return yield* new CacheCorrupted({ key });
29
+ }
30
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A domain failure should be a Schema.TaggedError with its own tag.
2
+ description: A domain failure must be a Schema.TaggedError with its own tag, never a bare Error or a plain object.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  class ValidationError extends Schema.TaggedError<ValidationError>()("ValidationError", {
7
7
  field: Schema.String,
8
8
  message: Schema.String,
@@ -13,3 +13,7 @@ class NotFoundError extends Schema.TaggedError<NotFoundError>()("NotFoundError",
13
13
  id: Schema.String,
14
14
  }) {}
15
15
  ```
16
+
17
+ ```ts never
18
+ return yield* Effect.fail(new Error("Card declined"));
19
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: An error from an external library should be wrapped in a tagged error with a Schema.Defect field, not passed through raw.
2
+ description: An error from an external library must be wrapped in a tagged error with a Schema.Defect field, never passed through raw.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  class ApiError extends Schema.TaggedError<ApiError>()("ApiError", {
7
7
  endpoint: Schema.String,
8
8
  statusCode: Schema.Number,
@@ -22,3 +22,8 @@ const fetchUser = (id: string) =>
22
22
  ),
23
23
  );
24
24
  ```
25
+
26
+ ```ts never
27
+ const charge = (amount: number) =>
28
+ Effect.tryPromise(() => stripe.charges.create({ amount, currency: "usd" }));
29
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: File, path, process, terminal, and HTTP access goes through Effect's platform services (FileSystem, Path, ChildProcess, Terminal, HttpClient, KeyValueStore), acquired with yield*, not through node: builtins, Bun globals, or fetch. The Bun implementations should be provided once at the entry point with BunServices.layer.
2
+ description: File, path, process, terminal, and HTTP access must go through Effect's platform services (FileSystem, Path, ChildProcess, Terminal, HttpClient, KeyValueStore), acquired with yield*, never through node: builtins, Bun globals, or fetch. The Bun implementations must be provided once at the entry point with BunServices.layer.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  import { Effect, FileSystem, Path } from "effect";
7
7
  import { HttpClient, HttpClientResponse } from "effect/unstable/http";
8
8
  import { BunRuntime, BunServices } from "@effect/platform-bun";
@@ -18,3 +18,9 @@ const program = Effect.gen(function* () {
18
18
 
19
19
  program.pipe(Effect.provide(BunServices.layer), BunRuntime.runMain);
20
20
  ```
21
+
22
+ ```ts never
23
+ import { readFileSync } from "node:fs";
24
+
25
+ const settings = readFileSync("settings.json", "utf8");
26
+ ```