@drkmttr/adhere 0.6.1 → 0.7.1

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 (38) hide show
  1. package/README.md +77 -31
  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 +71 -36
  31. package/src/config.ts +72 -13
  32. package/src/init.ts +10 -8
  33. package/src/markdown.ts +59 -25
  34. package/src/services/Jev.http.ts +27 -5
  35. package/src/services/Jev.ts +45 -31
  36. package/src/services/SourceWalker.ts +47 -23
  37. package/src/workflows/audit.ts +108 -61
  38. package/src/workflows/format.ts +51 -8
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # adhere
2
2
 
3
3
  A linter for rules a normal linter cannot check. A rule is a one-sentence
4
- description like "business logic should live in services", plus correct
5
- reference code / code to avoid / or both. For each
6
- 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
7
7
  model) whether the file breaks each rule, gets a calibrated probability per
8
8
  rule, and reports the ones above a threshold with the line Jev points at. The
9
9
  report uses the same frame as `vp lint`.
@@ -61,8 +61,8 @@ Found 1 error.
61
61
  ```
62
62
 
63
63
  The header is the rule id, Jev's probability, and the rule's description. The
64
- hint is the rule's reference; a rule with only code to avoid shows that code,
65
- 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
66
66
  in it is highlighted. The exit code is 0 when nothing is reported and 1 when
67
67
  something is. It is also 1 when the run refuses, for example on an invalid
68
68
  config or rule file, a missing API key, or an unknown command or flag; a
@@ -73,8 +73,11 @@ refusal prints its reason.
73
73
  ```sh
74
74
  adhere lint # audit the working directory
75
75
  adhere lint --preset effect # add a built-in rule set; the config becomes optional
76
- 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
77
77
  adhere lint --yes # send the requests without asking first
78
+ adhere lint --limit 500 # judge at most 500 checks; the rest wait for the next run
79
+ adhere lint --rpm 30 # send at most 30 requests a minute
80
+ adhere lint --filter 'src/**' # read only the files a glob matches
78
81
  adhere validate # load the config and rules, ask Jev whether any contradict
79
82
  adhere init [--force] # scaffold .adhere/config.ts and two example rules
80
83
  adhere login # save a TypeSafe AI API key for later runs
@@ -104,6 +107,21 @@ the report goes to stdout. A request Jev refuses stops the run with the file,
104
107
  the HTTP status, and Jev's reason. Files finished before it are cached, so
105
108
  running again continues from there.
106
109
 
110
+ ### Limiting a run
111
+
112
+ Three flags bound what a run does. `--limit <checks>` judges at most that many
113
+ checks, taken in path order; the rest wait, and since judgments are cached, the
114
+ next run with the same limit picks up where this one stopped. `--limit 0` shows
115
+ the plan and judges nothing. `--rpm <requests>` sends at most that many requests
116
+ to Jev a minute, evenly spaced, retries included, and the plan says about how
117
+ long they take. `--filter <glob>` reads only the files whose path from the
118
+ working directory matches, such as `src/**` or `**/*.service.ts`; repeat it for
119
+ more, and start a pattern with `!` to leave out what it matches. Together:
120
+
121
+ ```sh
122
+ adhere lint --filter 'packages/api/**' --filter '!**/generated/**' --limit 200 --rpm 30
123
+ ```
124
+
107
125
  ### What gets read
108
126
 
109
127
  `adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
@@ -161,17 +179,17 @@ import type { Config } from "@drkmttr/adhere";
161
179
 
162
180
  export default {
163
181
  model: "jev-latest", // optional, default "jev-latest"
164
- threshold: 0.7, // optional, default 0.7
182
+ threshold: 0.8, // optional, default 0.8
165
183
  presets: ["effect"], // optional, built-in rule sets
166
184
  rules: {
167
185
  "data/brand-meaningful-primitives": {
168
186
  description:
169
- "A primitive with semantic meaning, such as an id, email, URL, port, or count, should be a branded schema.",
170
- reference: `
187
+ "A primitive with semantic meaning, such as an id, email, URL, port, or count, must be a branded schema.",
188
+ must: `
171
189
  const UserId = Schema.String.pipe(Schema.brand("UserId"))
172
190
  type UserId = typeof UserId.Type
173
191
  `,
174
- avoid: "type UserId = string", // optional: what a violation looks like
192
+ never: "type UserId = string", // optional: what a violation looks like
175
193
  threshold: 0.8, // optional per-rule override
176
194
  },
177
195
  },
@@ -196,44 +214,54 @@ config and the cache (neither is read as a rule). The path without `.md` is the
196
214
  rule id, so `.adhere/data/brand-ports.md` is `data/brand-ports`. When that
197
215
  directory exists, it is read without any config.
198
216
 
199
- A file is front matter, then a body. The first fenced code block in the body
200
- is the reference; prose around it renders on GitHub and is ignored. Without a
201
- fence, the whole body is the reference. A fence tagged `avoid` after its
202
- language holds code to avoid instead: what a violation looks like.
217
+ A file is front matter, then a body. The body's code goes in fences tagged,
218
+ after the language, with RFC 2119's words: `must` for code that must be
219
+ written, and `never` for code that must never be, which is what a violation
220
+ looks like. Prose around them renders on GitHub and is ignored. An untagged
221
+ fence is code that must be written, and without a fence the whole body is.
222
+ `avoid`, the tag before 0.7, reads as `never`.
203
223
 
204
224
  ````md
205
225
  ---
206
- description: A port is a branded, range-checked integer, not a bare number.
226
+ description: A port must be a branded, range-checked integer, never a bare number.
207
227
  threshold: 0.8
208
228
  ---
209
229
 
210
230
  Why: a bare `number` accepts 70000 and -1.
211
231
 
212
- ```ts
232
+ ```ts must
213
233
  const Port = Schema.Int.pipe(
214
234
  Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
215
235
  Schema.brand("Port"),
216
236
  );
217
237
  ```
218
238
 
219
- Not this:
220
-
221
- ```ts avoid
239
+ ```ts never
222
240
  const port: number = Number(process.env.PORT);
223
241
  ```
224
242
  ````
225
243
 
226
- `description` is required, with a reference, code to avoid, or both.
244
+ `description` is required, with code under `must`, `never`, or both.
227
245
  `threshold` is optional. A file that fails validation refuses the run with its
228
246
  path in the message. `loadRules(directory)` from the package root does the same
229
247
  load for your own tooling.
230
248
 
231
- With a reference, Jev is asked whether the file diverges from it, and code to
232
- avoid, when there is some, is its example of diverging. With only code to
233
- avoid, Jev is asked whether the file contains it. That suits a rule with no
234
- single correct form to show, such as a hand-rolled retry loop or an error
235
- caught and dropped. Neither goes in the other's place: code to avoid in the
236
- reference reads to Jev as the pattern to follow.
249
+ Jev reads the code under the same words, and is asked whether the file
250
+ diverges from the pattern `must` shows, with `never` as an example of
251
+ diverging. Write the description in them too, so the rule and its code agree:
252
+ "must" and "never", as above.
253
+
254
+ A rule with only code that must never be written suits a rule with no single
255
+ correct form to show, such as a hand-rolled retry loop or an error caught and
256
+ dropped. Neither goes in the other's place: code that must never be written,
257
+ under `must`, reads to Jev as the pattern to follow.
258
+
259
+ A rule that is a guideline rather than a requirement says "should" instead:
260
+ in its description, and in fences tagged `should` and `should not` (`should`
261
+ and `shouldNot` inline in a config). Jev then reads the code under `should`
262
+ and `should_not`. A rule is one or the other, so it cannot mix `must` or
263
+ `never` with `should` or `should not`. In a config, `reference` and `avoid`,
264
+ the names before 0.7, read as `must` and `never`.
237
265
 
238
266
  Nested `.adhere/` directories are also discovered, except under
239
267
  `node_modules/`, `dist/`, and the other skipped directories (above). A rule in
@@ -260,6 +288,22 @@ export default { presets: ["effect"], rules: "./docs/adhere" } satisfies Config;
260
288
 
261
289
  Rules read through `rules` apply project-wide.
262
290
 
291
+ ### Rule writing tips
292
+
293
+ We've evaluated different ways of giving Jev a rule, to catch the most
294
+ violations with the fewest false positives. In general:
295
+
296
+ - Say "must" for what code must do and "never" for what it must not, in the
297
+ description and as the fence tags.
298
+ - Give one example of each: one `must` block and one `never` block. Three of
299
+ each did no better, and several of one kind alone did worse.
300
+ - For a guideline rather than a requirement, say "should" and "should not"
301
+ the same way.
302
+
303
+ On the Effect preset, rules written this way cut the findings Jev got wrong at
304
+ the default threshold from 6 to 1, and caught as many violations. For more
305
+ about the evaluations, see [the eval](eval/README.md).
306
+
263
307
  ### Presets
264
308
 
265
309
  A preset has the shape of a config without `presets`: `rules`, and optionally
@@ -273,7 +317,7 @@ case the config file is optional.
273
317
  ### Precedence
274
318
 
275
319
  Highest first: `--threshold` on the command line, the config file, presets in
276
- order (a later preset wins), then the defaults `jev-latest` and `0.7`. A rule
320
+ order (a later preset wins), then the defaults `jev-latest` and `0.8`. A rule
277
321
  in `rules` replaces a preset rule with the same id. A rule's own `threshold`
278
322
  beats all of the above for that rule.
279
323
 
@@ -281,10 +325,12 @@ beats all of the above for that rule.
281
325
 
282
326
  1. One Jev request per file. The state is the file's numbered lines and
283
327
  nothing else. Each rule is one `noul` (yes/no probability) question that
284
- carries the rule's description, reference, and code to avoid: whether the
285
- file diverges from the reference or, for a rule with only code to avoid,
286
- whether it contains that code. Criteria for each answer draw the line at
287
- the rule's scope, so a file with no code the rule is about is a no. Since
328
+ carries the rule's description and its code under its words, `must` and
329
+ `never` (or a guideline's `should` and `should_not`): whether the file
330
+ diverges from the pattern `must` shows, with `never` as an example of
331
+ diverging, or, for a rule with only code that must never be written,
332
+ whether the file contains that code. Criteria for each answer draw the line
333
+ at the rule's scope, so a file with no code the rule is about is a no. Since
288
334
  no rule sits in the shared state, a rule's probability depends only on the
289
335
  file and that rule, not on which other rules share the request. Every
290
336
  question shares the state cost of the request.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
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.1",
71
- "@drkmttr/adhere-darwin-x64": "0.6.1",
72
- "@drkmttr/adhere-linux-arm64": "0.6.1",
73
- "@drkmttr/adhere-linux-x64": "0.6.1",
74
- "@drkmttr/adhere-win32-x64": "0.6.1"
70
+ "@drkmttr/adhere-darwin-arm64": "0.7.1",
71
+ "@drkmttr/adhere-darwin-x64": "0.7.1",
72
+ "@drkmttr/adhere-linux-arm64": "0.7.1",
73
+ "@drkmttr/adhere-linux-x64": "0.7.1",
74
+ "@drkmttr/adhere-win32-x64": "0.7.1"
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
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: Each it.effect provides its own layer. it.layer should be used only to share an expensive resource across a suite.
2
+ description: Each it.effect must provide its own layer. it.layer must be used only to share an expensive resource across a suite, never a cheap one.
3
3
  ---
4
4
 
5
- ```ts
5
+ ```ts must
6
6
  it.effect("starts at zero", () =>
7
7
  Effect.gen(function* () {
8
8
  const counter = yield* Counter;
@@ -18,3 +18,10 @@ it.effect("increments without leaking", () =>
18
18
  }).pipe(Effect.provide(Counter.layer)),
19
19
  );
20
20
  ```
21
+
22
+ ```ts never
23
+ it.layer(Counter.layer)("Counter", (it) => {
24
+ it.effect("starts at zero", () => Effect.gen(function* () { /* ... */ }));
25
+ it.effect("increments", () => Effect.gen(function* () { /* ... */ }));
26
+ });
27
+ ```