@drkmttr/adhere 0.6.1 → 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.
- package/README.md +59 -31
- package/package.json +7 -7
- package/presets/effect/basics/external-calls-are-resilient.md +6 -2
- package/presets/effect/basics/fn-for-named-effects.md +10 -2
- package/presets/effect/basics/gen-for-sequencing.md +10 -2
- package/presets/effect/basics/instrument-with-pipe.md +9 -2
- package/presets/effect/cli/handlers-delegate.md +13 -2
- package/presets/effect/config/business-logic-depends-on-config-service.md +9 -2
- package/presets/effect/config/secrets-are-redacted.md +6 -2
- package/presets/effect/config/tests-provide-values-directly.md +8 -2
- package/presets/effect/config/validate-with-schema.md +9 -2
- package/presets/effect/data/brand-meaningful-primitives.md +11 -2
- package/presets/effect/data/decode-json-with-schema.md +6 -2
- package/presets/effect/data/records-are-schema-classes.md +9 -2
- package/presets/effect/data/variants-are-tagged-unions.md +9 -2
- package/presets/effect/errors/catch-defects-at-boundaries-only.md +6 -2
- package/presets/effect/errors/defects-for-bugs.md +13 -2
- package/presets/effect/errors/domain-errors-are-tagged.md +6 -2
- package/presets/effect/errors/wrap-external-errors.md +7 -2
- package/presets/effect/platform/os-access-through-platform-services.md +8 -2
- package/presets/effect/services/fresh-layer-per-test.md +9 -2
- package/presets/effect/services/memoize-parameterized-layers.md +9 -2
- package/presets/effect/services/methods-have-no-requirements.md +9 -2
- package/presets/effect/services/no-mutable-state.md +9 -2
- package/presets/effect/services/provide-at-entry.md +7 -2
- package/presets/effect/services/test-layers-are-in-memory.md +12 -2
- package/presets/effect/testing/test-clock-for-time.md +11 -2
- package/presets/effect/testing/test-random.md +9 -2
- package/skills/adhere/SKILL.md +24 -17
- package/src/cli.ts +2 -2
- package/src/config.ts +67 -13
- package/src/init.ts +10 -8
- package/src/markdown.ts +59 -25
- package/src/services/Jev.ts +45 -31
- package/src/workflows/audit.ts +3 -5
- package/src/workflows/format.ts +7 -3
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
|
|
5
|
-
|
|
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
|
|
65
|
-
labeled `
|
|
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,7 +73,7 @@ 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.
|
|
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
78
|
adhere validate # load the config and rules, ask Jev whether any contradict
|
|
79
79
|
adhere init [--force] # scaffold .adhere/config.ts and two example rules
|
|
@@ -161,17 +161,17 @@ import type { Config } from "@drkmttr/adhere";
|
|
|
161
161
|
|
|
162
162
|
export default {
|
|
163
163
|
model: "jev-latest", // optional, default "jev-latest"
|
|
164
|
-
threshold: 0.
|
|
164
|
+
threshold: 0.8, // optional, default 0.8
|
|
165
165
|
presets: ["effect"], // optional, built-in rule sets
|
|
166
166
|
rules: {
|
|
167
167
|
"data/brand-meaningful-primitives": {
|
|
168
168
|
description:
|
|
169
|
-
"A primitive with semantic meaning, such as an id, email, URL, port, or count,
|
|
170
|
-
|
|
169
|
+
"A primitive with semantic meaning, such as an id, email, URL, port, or count, must be a branded schema.",
|
|
170
|
+
must: `
|
|
171
171
|
const UserId = Schema.String.pipe(Schema.brand("UserId"))
|
|
172
172
|
type UserId = typeof UserId.Type
|
|
173
173
|
`,
|
|
174
|
-
|
|
174
|
+
never: "type UserId = string", // optional: what a violation looks like
|
|
175
175
|
threshold: 0.8, // optional per-rule override
|
|
176
176
|
},
|
|
177
177
|
},
|
|
@@ -196,44 +196,54 @@ config and the cache (neither is read as a rule). The path without `.md` is the
|
|
|
196
196
|
rule id, so `.adhere/data/brand-ports.md` is `data/brand-ports`. When that
|
|
197
197
|
directory exists, it is read without any config.
|
|
198
198
|
|
|
199
|
-
A file is front matter, then a body. The
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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`.
|
|
203
205
|
|
|
204
206
|
````md
|
|
205
207
|
---
|
|
206
|
-
description: A port
|
|
208
|
+
description: A port must be a branded, range-checked integer, never a bare number.
|
|
207
209
|
threshold: 0.8
|
|
208
210
|
---
|
|
209
211
|
|
|
210
212
|
Why: a bare `number` accepts 70000 and -1.
|
|
211
213
|
|
|
212
|
-
```ts
|
|
214
|
+
```ts must
|
|
213
215
|
const Port = Schema.Int.pipe(
|
|
214
216
|
Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
|
|
215
217
|
Schema.brand("Port"),
|
|
216
218
|
);
|
|
217
219
|
```
|
|
218
220
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
```ts avoid
|
|
221
|
+
```ts never
|
|
222
222
|
const port: number = Number(process.env.PORT);
|
|
223
223
|
```
|
|
224
224
|
````
|
|
225
225
|
|
|
226
|
-
`description` is required, with
|
|
226
|
+
`description` is required, with code under `must`, `never`, or both.
|
|
227
227
|
`threshold` is optional. A file that fails validation refuses the run with its
|
|
228
228
|
path in the message. `loadRules(directory)` from the package root does the same
|
|
229
229
|
load for your own tooling.
|
|
230
230
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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`.
|
|
237
247
|
|
|
238
248
|
Nested `.adhere/` directories are also discovered, except under
|
|
239
249
|
`node_modules/`, `dist/`, and the other skipped directories (above). A rule in
|
|
@@ -260,6 +270,22 @@ export default { presets: ["effect"], rules: "./docs/adhere" } satisfies Config;
|
|
|
260
270
|
|
|
261
271
|
Rules read through `rules` apply project-wide.
|
|
262
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
|
+
|
|
263
289
|
### Presets
|
|
264
290
|
|
|
265
291
|
A preset has the shape of a config without `presets`: `rules`, and optionally
|
|
@@ -273,7 +299,7 @@ case the config file is optional.
|
|
|
273
299
|
### Precedence
|
|
274
300
|
|
|
275
301
|
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.
|
|
302
|
+
order (a later preset wins), then the defaults `jev-latest` and `0.8`. A rule
|
|
277
303
|
in `rules` replaces a preset rule with the same id. A rule's own `threshold`
|
|
278
304
|
beats all of the above for that rule.
|
|
279
305
|
|
|
@@ -281,10 +307,12 @@ beats all of the above for that rule.
|
|
|
281
307
|
|
|
282
308
|
1. One Jev request per file. The state is the file's numbered lines and
|
|
283
309
|
nothing else. Each rule is one `noul` (yes/no probability) question that
|
|
284
|
-
carries the rule's description
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
288
316
|
no rule sits in the shared state, a rule's probability depends only on the
|
|
289
317
|
file and that rule, not on which other rules share the request. Every
|
|
290
318
|
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.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "Lints a repository against
|
|
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.
|
|
71
|
-
"@drkmttr/adhere-darwin-x64": "0.
|
|
72
|
-
"@drkmttr/adhere-linux-arm64": "0.
|
|
73
|
-
"@drkmttr/adhere-linux-x64": "0.
|
|
74
|
-
"@drkmttr/adhere-win32-x64": "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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
```
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: The result of a parameterized layer constructor
|
|
2
|
+
description: The result of a parameterized layer constructor must be stored in a module constant before it is used in more than one place, never constructed again for each use.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
```ts
|
|
5
|
+
```ts must
|
|
6
6
|
const postgresLayer = Postgres.layer({ url: "postgres://localhost/mydb", poolSize: 10 });
|
|
7
7
|
|
|
8
8
|
const goodAppLayer = Layer.merge(
|
|
@@ -10,3 +10,10 @@ const goodAppLayer = Layer.merge(
|
|
|
10
10
|
OrderRepo.layer.pipe(Layer.provide(postgresLayer)),
|
|
11
11
|
);
|
|
12
12
|
```
|
|
13
|
+
|
|
14
|
+
```ts never
|
|
15
|
+
const Live = Layer.merge(
|
|
16
|
+
Sessions.layer.pipe(Layer.provide(Redis.layer({ host: "localhost" }))),
|
|
17
|
+
RateLimits.layer.pipe(Layer.provide(Redis.layer({ host: "localhost" }))),
|
|
18
|
+
);
|
|
19
|
+
```
|