@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.
- package/README.md +75 -39
- 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.http.ts +36 -11
- package/src/services/Jev.ts +75 -36
- package/src/workflows/audit.ts +34 -21
- 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
|
|
5
|
-
|
|
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
|
-
|
|
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`.
|
|
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.
|
|
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 #
|
|
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
|
|
59
|
-
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
|
|
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.
|
|
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.
|
|
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,
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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.
|
|
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
|
|
279
|
-
|
|
280
|
-
the
|
|
281
|
-
or
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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.
|
|
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
|
+
```
|