@drkmttr/adhere 0.7.3 → 0.9.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 (80) hide show
  1. package/README.md +177 -37
  2. package/package.json +12 -12
  3. package/presets/alchemy/apis/match-remote-errors-by-tag.md +15 -0
  4. package/presets/alchemy/apis/schemas-at-trust-boundaries.md +18 -0
  5. package/presets/alchemy/data/commit-migrations-then-deploy.md +15 -0
  6. package/presets/alchemy/data/hyperdrive-direct-origin.md +14 -0
  7. package/presets/alchemy/data/migrations-use-direct-url.md +11 -0
  8. package/presets/alchemy/data/never-disable-tls-verification.md +16 -0
  9. package/presets/alchemy/data/parameterized-sql.md +13 -0
  10. package/presets/alchemy/data/private-endpoint-at-runtime.md +12 -0
  11. package/presets/alchemy/data/single-migration-owner.md +13 -0
  12. package/presets/alchemy/durable/idempotent-event-handlers.md +15 -0
  13. package/presets/alchemy/durable/io-inside-steps.md +17 -0
  14. package/presets/alchemy/durable/no-self-triggering-bucket-writes.md +15 -0
  15. package/presets/alchemy/durable/stable-step-names.md +11 -0
  16. package/presets/alchemy/durable/webhook-state-is-truth.md +14 -0
  17. package/presets/alchemy/providers/diff-guards-unresolved-props.md +16 -0
  18. package/presets/alchemy/providers/idempotent-delete.md +14 -0
  19. package/presets/alchemy/providers/lazy-layer-construction.md +14 -0
  20. package/presets/alchemy/providers/reconcile-from-observed-state.md +20 -0
  21. package/presets/alchemy/resources/anchor-paths-with-import-meta.md +14 -0
  22. package/presets/alchemy/resources/idempotent-actions-and-seeds.md +19 -0
  23. package/presets/alchemy/resources/previews-reference-shared-resources.md +15 -0
  24. package/presets/alchemy/resources/protect-irreplaceable-data.md +12 -0
  25. package/presets/alchemy/resources/remote-state-for-ci.md +22 -0
  26. package/presets/alchemy/resources/stable-logical-ids.md +13 -0
  27. package/presets/alchemy/resources/stage-unique-physical-names.md +15 -0
  28. package/presets/alchemy/runtime/bind-tag-not-implementation.md +27 -0
  29. package/presets/alchemy/runtime/deterministic-config-defaults.md +13 -0
  30. package/presets/alchemy/runtime/disposables-in-request-scope.md +27 -0
  31. package/presets/alchemy/runtime/lambda-no-post-response-work.md +19 -0
  32. package/presets/alchemy/runtime/no-per-request-work-at-construction.md +24 -0
  33. package/presets/alchemy/runtime/resolve-config-and-bindings-at-construction.md +31 -0
  34. package/presets/alchemy/secrets/let-alchemy-mint-infra-secrets.md +13 -0
  35. package/presets/alchemy/secrets/never-logged-returned-or-output.md +15 -0
  36. package/presets/alchemy/secrets/none-in-browser-env.md +15 -0
  37. package/presets/alchemy/secrets/redacted-until-use.md +19 -0
  38. package/presets/alchemy/secrets/unwrap-before-compare-or-send.md +17 -0
  39. package/presets/alchemy/security/authorize-public-endpoints.md +20 -0
  40. package/presets/alchemy/security/github-secret-not-variable.md +12 -0
  41. package/presets/alchemy/security/identity-from-verified-session.md +14 -0
  42. package/presets/alchemy/security/keep-auth-protections-on.md +19 -0
  43. package/presets/alchemy/security/least-privilege-bindings.md +13 -0
  44. package/presets/alchemy/security/least-privilege-ci-credentials.md +24 -0
  45. package/presets/alchemy/security/lookup-failure-is-not-anonymous.md +15 -0
  46. package/presets/effect/basics/external-calls-are-resilient.md +3 -3
  47. package/presets/effect/config/tests-provide-values-directly.md +3 -3
  48. package/presets/effect/config/validate-with-schema.md +13 -2
  49. package/presets/effect/data/variants-are-tagged-unions.md +1 -1
  50. package/presets/effect/services/dependencies-through-layers.md +42 -0
  51. package/presets/effect/services/operations-have-no-requirements.md +46 -0
  52. package/presets/effect/services/test-layers-are-in-memory.md +3 -3
  53. package/presets/effect/testing/test-clock-for-time.md +1 -1
  54. package/skills/adhere/SKILL.md +3 -2
  55. package/src/cli.ts +42 -16
  56. package/src/config.ts +12 -2
  57. package/src/excerpt.ts +255 -0
  58. package/src/highlight.ts +82 -20
  59. package/src/init.ts +10 -4
  60. package/src/main.ts +35 -4
  61. package/src/mechanical.ts +89 -0
  62. package/src/presets.ts +45 -8
  63. package/src/rules.ts +2 -0
  64. package/src/services/AdhereConfig.ts +18 -20
  65. package/src/services/AuditCache.ts +272 -23
  66. package/src/services/Jev.http.ts +66 -12
  67. package/src/services/Jev.ts +66 -6
  68. package/src/services/SourceWalker.ts +11 -4
  69. package/src/wording.ts +90 -0
  70. package/src/workflows/audit.ts +169 -26
  71. package/src/workflows/format.ts +35 -18
  72. package/presets/effect/basics/fn-for-named-effects.md +0 -19
  73. package/presets/effect/cli/handlers-delegate.md +0 -24
  74. package/presets/effect/data/decode-json-with-schema.md +0 -18
  75. package/presets/effect/data/records-are-schema-classes.md +0 -23
  76. package/presets/effect/errors/domain-errors-are-tagged.md +0 -19
  77. package/presets/effect/platform/os-access-through-platform-services.md +0 -26
  78. package/presets/effect/services/methods-have-no-requirements.md +0 -20
  79. package/presets/effect/services/provide-at-entry.md +0 -23
  80. package/presets/effect/testing/test-random.md +0 -22
package/README.md CHANGED
@@ -8,9 +8,10 @@ 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`.
10
10
 
11
- Deterministic rules (substring matches, type checks) belong in a normal linter.
11
+ Deterministic rules (substring matches, type checks) belong in a normal linter;
12
+ `adhere validate` points out a rule that looks like one.
12
13
 
13
- A preset for effect is included which can be run without setup:
14
+ A preset for effect is included which can be run without setup:
14
15
 
15
16
  ```sh
16
17
  TYPESAFE_API_KEY=xxx npx @drkmttr/adhere lint --preset effect
@@ -36,20 +37,25 @@ with a TypeSafe AI API key: the one `adhere login` saved, or
36
37
 
37
38
  ```sh
38
39
  adhere init # .adhere/config.ts and two example rules
39
- echo ".adhere/cache/" >> .gitignore
40
40
  adhere login # save your TypeSafe AI API key, once
41
- adhere validate # Checks your rules for contradictions using Jev
41
+ adhere validate # Checks your rules' wording, and contradictions using Jev
42
42
  adhere lint # audit the working directory
43
+ git add .adhere # commit the rules, and the judgments they cost
43
44
  ```
44
45
 
45
46
  Without a config or rules, `adhere lint --preset effect` audits against the
46
47
  built-in Effect rules. A finding looks like this:
47
48
 
48
49
  ```text
49
- × data/brand-ports (0.93): A port is a branded, range-checked integer, not a bare number.
50
- ╭─[src/server.ts:4:1]
51
- 4 │ const port: number = Number(process.env.PORT ?? 3000);
52
- · ──────────────────────────────────────────────────────
50
+ × data/brand-ports (0.93): A port must be a branded, range-checked integer, never a bare number.
51
+ ╭─[src/server.ts:6:3]
52
+ 4 │ export const serve = Effect.gen(function* () {
53
+ 5 │ const host = process.env.HOST ?? "localhost";
54
+ 6 │ const port: number = Number(process.env.PORT ?? 3000);
55
+ · ──────────────────────────────────────────────────────
56
+ 7 │ yield* listen({ host, port });
57
+ 8 │ yield* Effect.log(`Listening on ${host}:${port}`);
58
+ 9 │ });
53
59
  ╰────
54
60
  hint: const Port = Schema.Int.pipe(
55
61
  Schema.check(Schema.isBetween({ minimum: 1, maximum: 65535 })),
@@ -60,7 +66,10 @@ Found 1 error.
60
66
  42 files, 3 judged, 39 cached.
61
67
  ```
62
68
 
63
- The header is the rule id, Jev's probability, and the rule's description. The
69
+ The header is the rule id, Jev's probability, and the rule's description.
70
+ Under it is the line Jev points at, underlined, with the code around it: the
71
+ largest statement around the line that is 30 lines or fewer, usually the whole
72
+ function, and up to 3 lines of whole statements on either side. The
64
73
  hint is the rule's code that must be written; a rule with only code that must
65
74
  never be written shows that code, labeled `never:`, instead. On a terminal, the report is in color and the code
66
75
  in it is highlighted. The exit code is 0 when nothing is reported and 1 when
@@ -78,7 +87,8 @@ adhere lint --yes # send the requests without asking first
78
87
  adhere lint --limit 500 # judge at most 500 checks; the rest wait for the next run
79
88
  adhere lint --rpm 30 # send at most 30 requests a minute
80
89
  adhere lint --filter 'src/**' # read only the files a glob matches
81
- adhere validate # load the config and rules, ask Jev whether any contradict
90
+ adhere lint --log-level debug # log each request to Jev on stderr
91
+ adhere validate # check the rules' wording; ask Jev whether any contradict
82
92
  adhere init [--force] # scaffold .adhere/config.ts and two example rules
83
93
  adhere login # save a TypeSafe AI API key for later runs
84
94
  adhere logout # delete the saved key
@@ -129,6 +139,22 @@ more, and start a pattern with `!` to leave out what it matches. Together:
129
139
  adhere lint --filter 'packages/api/**' --filter '!**/generated/**' --limit 200 --rpm 30
130
140
  ```
131
141
 
142
+ ### Logging a run
143
+
144
+ `--log-level debug` logs a run's work on stderr: the config it loaded, how many
145
+ paths it listed and how many of them it reads, and each request to Jev, with
146
+ the file it is for, about how many tokens it carries, the HTTP status, how long
147
+ it took, and Cloudflare's Ray ID. A failed attempt is logged even when a retry
148
+ hides it. `--log-level trace` adds each file as it is read, planned, and
149
+ answered from the cache, and the first 4000 characters of any error Jev's API
150
+ answers with. The counter stays off at these levels, since its redraws would
151
+ garble the lines, and stdout still carries only the report, so the log can go
152
+ to a file of its own:
153
+
154
+ ```sh
155
+ adhere lint --log-level debug 2> adhere.log
156
+ ```
157
+
132
158
  ### What gets read
133
159
 
134
160
  `adhere lint` reads the `.ts` files under the working directory, except `.d.ts`,
@@ -150,16 +176,45 @@ kept, and no second one is added, even with `--force`.
150
176
 
151
177
  `adhere validate` loads the config, rule files, and presets the way `lint`
152
178
  does, so a file that fails to decode refuses here too, and it prints how many
153
- rules it loaded. `--preset` adds a built-in rule set, as for `lint`. It then
154
- asks Jev whether any two rules that apply to the same files contradict, so that
155
- no code can follow both: one request names, per rule, the rule it conflicts
156
- with, if any, and each named pair then gets a probability from a request of
157
- its own, holding only those two rules, since other rules beside them dilute
158
- the judgment. A pair above
159
- the threshold is printed with both rule files, and the exit code is 1. Two
160
- rules with the same id are not compared: a nested rule that shares an id
161
- shadows the other on purpose. When no two rules share files nothing is sent;
162
- otherwise `validate` needs the API key, as `lint` does.
179
+ rules it loaded. `--preset` adds a built-in rule set, as for `lint`.
180
+
181
+ Next it checks each rule's wording against the
182
+ [rule writing tips](#rule-writing-tips), which sends nothing, and prints each
183
+ rule worded otherwise with its file and how:
184
+
185
+ - its description does not say a word its code is under, such as "never"
186
+ beside a `never` block;
187
+ - it has code to write but no example of code that breaks it;
188
+ - its description says the other kind of rule's words: "should" in a
189
+ requirement, or "must" or "never" in a guideline.
190
+
191
+ A rule with only code that must never be written needs no `must` example, as
192
+ [Rules as Markdown files](#rules-as-markdown-files) explains. The tips are
193
+ advice, so the wording does not change the exit code.
194
+
195
+ Then it reports the linter check, which also sends nothing. While `lint`
196
+ judges a file against one of the project's own rules, it asks Jev a second
197
+ question beside it: should the rule have been checked by a regular linter, that
198
+ is, could a linter or type checker have decided exactly whether this file
199
+ follows it? It asks on 10 files per rule, spread over the files it judges, and
200
+ keeps the answers in the cache until the rule's text changes. `validate` prints
201
+ each rule flagged on 7 or more of its 10 files as one a regular linter should
202
+ probably check, and each flagged on 3 to 6 as a rule that reads differently
203
+ from file to file, whose description should say more precisely what it
204
+ applies to. A rule with fewer than 10 answers is counted as waiting; `lint`
205
+ collects them as it judges files, so a repo whose files are all cached collects
206
+ them as its files change. Preset rules are left out: they are not the
207
+ project's to change. The linter check is advice too.
208
+
209
+ It then asks Jev whether any two rules that apply to the same files
210
+ contradict, so that no code can follow both: one request names, per rule, the
211
+ rule it conflicts with, if any, and each named pair then gets a probability
212
+ from a request of its own, holding only those two rules, since other rules
213
+ beside them dilute the judgment. A pair above the threshold is printed with
214
+ both rule files, and the exit code is 1. Two rules with the same id are not
215
+ compared: a nested rule that shares an id shadows the other on purpose. When
216
+ no two rules share files nothing is sent; otherwise `validate` needs the API
217
+ key, as `lint` does.
163
218
 
164
219
  ### Login
165
220
 
@@ -309,17 +364,73 @@ violations with the fewest false positives. In general:
309
364
 
310
365
  On the Effect preset, rules written this way cut the findings Jev got wrong at
311
366
  the default threshold from 6 to 1, and caught as many violations. For more
312
- about the evaluations, see [the eval](eval/README.md).
367
+ about the evaluations, see [the eval](eval/README.md). `adhere validate` lists
368
+ each rule worded otherwise.
313
369
 
314
370
  ### Presets
315
371
 
316
372
  A preset has the shape of a config without `presets`: `rules`, and optionally
317
- `model` and `threshold`. `effect` is the one preset: 26 Markdown rules in
318
- [`presets/effect/`](./presets/effect/), lifted from the
319
- [effect-solutions](https://github.com/kitlangton/effect-solutions) docs and the
320
- [effect/platform](https://effect.website/docs/platform/introduction/) docs.
321
- Name it in the config, or on the command line with `--preset effect`, in which
322
- case the config file is optional.
373
+ `model` and `threshold`. Name one in the config, or on the command line with
374
+ `--preset`, in which case the config file is optional. There are two:
375
+
376
+ - `effect`: Markdown rules in [`presets/effect/`](./presets/effect/), lifted
377
+ from the [effect-solutions](https://github.com/kitlangton/effect-solutions)
378
+ docs and the
379
+ [effect/platform](https://effect.website/docs/platform/introduction/) docs.
380
+ - `alchemy`: 43 rules in [`presets/alchemy/`](./presets/alchemy/) for code that
381
+ deploys with [alchemy](https://alchemy.run), lifted from its docs and blog:
382
+ where Config and bindings are read, which resources keep their data, how
383
+ secrets stay out of bundles and logs, authorization on public URLs,
384
+ migrations, durable workflows, and custom providers.
385
+
386
+ Each preset divides into topics, its subdirectories, and a topic is a preset
387
+ of its own: `--preset effect/basics` applies only the rules under
388
+ `presets/effect/basics/`, and `alchemy/secrets` only alchemy's secrets rules.
389
+ A topic's rules keep the ids they have in the whole preset, so a topic and its
390
+ preset share cached judgments, and naming both applies each rule once.
391
+
392
+ A preset rule says "must" only where its source makes a requirement, and
393
+ "should" where the source gives advice. In `effect`, three rules are
394
+ guidelines: network calls carry a timeout and a retry schedule, unless their
395
+ client already applies both; test layers are in memory, outside integration
396
+ tests; and tests provide config through a layer. Config validation accepts
397
+ `Config.mapOrFail` as well as `Config.schema`, and the variants rule does not
398
+ rule out a `switch`. The rule that a command handler only parses input is
399
+ gone: the docs show that pattern but do not ask for it.
400
+
401
+ The preset leaves out conventions a linter checks exactly. Effect's language
402
+ service, [`@effect/tsgo`](https://github.com/Effect-TS/tsgo) on TypeScript 7,
403
+ checks these seven, which the preset checked through 0.7. Most are off by
404
+ default; `npx @effect/tsgo setup` installs it, and these lines in its plugin
405
+ options in `tsconfig.json` turn them on:
406
+
407
+ ```jsonc
408
+ "diagnosticSeverity": {
409
+ "strictEffectProvide": "warning", // layers are provided once, at the entry point
410
+ "leakingRequirements": "warning", // service methods have no requirements
411
+ "effectFnOpportunity": "warning", // a named function returning an Effect uses Effect.fn
412
+ "preferSchemaOverJson": "warning", // JSON is decoded with Schema, not JSON.parse
413
+ "nodeBuiltinImport": "warning", // platform services, not node: builtins
414
+ "globalFetch": "warning",
415
+ "globalFetchInEffect": "warning",
416
+ "processEnv": "warning",
417
+ "processEnvInEffect": "warning",
418
+ "globalRandom": "warning", // the Random service, not Math.random
419
+ "globalRandomInEffect": "warning",
420
+ "globalErrorInEffectFailure": "warning", // tagged errors, not Error
421
+ "extendsNativeError": "warning"
422
+ }
423
+ ```
424
+
425
+ `npx @effect/tsgo diagnostics --project tsconfig.json` runs them in CI. Bun
426
+ globals, which the old platform rule also ruled out, need a lint rule of their
427
+ own, such as `no-restricted-globals`.
428
+
429
+ `leakingRequirements` sees a requirement in an operation's type, but not a
430
+ service built by a factory function that takes its dependencies as arguments,
431
+ whose types have no requirements to find. The preset's
432
+ `services/dependencies-through-layers` asks for that, and
433
+ `services/operations-have-no-requirements` for the style it goes with.
323
434
 
324
435
  ### Precedence
325
436
 
@@ -340,7 +451,9 @@ beats all of the above for that rule.
340
451
  at the rule's scope, so a file with no code the rule is about is a no. Since
341
452
  no rule sits in the shared state, a rule's probability depends only on the
342
453
  file and that rule, not on which other rules share the request. Every
343
- question shares the state cost of the request.
454
+ question shares the state cost of the request. On up to 10 files per
455
+ project rule, the rule's question has a second one beside it, the linter
456
+ check (see [Validate](#validate)), with the same fields.
344
457
  2. A second request only when at least one rule's probability is above its
345
458
  threshold: one `choice` question per flagged rule over the file's non-blank
346
459
  lines, which yields the line to report. A file with more than 255 non-blank
@@ -356,15 +469,42 @@ beats all of the above for that rule.
356
469
 
357
470
  ## Cache
358
471
 
359
- Judgments are cached in `.adhere/cache/` (add it to `.gitignore`), one entry
360
- per file. An entry stores the file's content hash and, per rule, the
361
- probability, the located line, and a fingerprint of the model and the
362
- question asked for the rule, which carries the rule's text. A changed file
363
- re-judges every rule for that file. An edited rule re-judges only that rule,
364
- and an adhere that asks its questions differently re-judges every rule once.
365
- A lowered threshold locates cached judgments that are newly above it without
366
- judging again. Entries depend on content, not on the
367
- machine, so restoring `.adhere/cache/` between CI runs skips unchanged files.
472
+ Judgments are cached in `.adhere/cache/`. Commit it. Every judgment is a paid
473
+ request, and Jev's answers vary a little from run to run, so a committed cache
474
+ gives everyone and CI the same findings without paying for them again, and a
475
+ pull request changes the judgments of only the files it changes.
476
+
477
+ `files/` holds Jev's answers by the content they are about: each cache file is
478
+ named for the hash of a source file's content, so a moved or copied file keeps
479
+ its judgments, and identical files share them. Each answer sits under a
480
+ fingerprint of the model and the question asked for the rule, which carries
481
+ the rule's text. A changed file re-judges every rule for that file. An edited
482
+ rule re-judges only that rule, and an adhere that asks its questions
483
+ differently re-judges every rule once. A lowered threshold locates cached
484
+ judgments that are newly above it without judging again. `tallies/` keeps the
485
+ linter check's answers, one tally per rule, under a key that changes with the
486
+ rule's text and the model.
487
+
488
+ A cache file is written once and never changed: it is also named for the hash
489
+ of its own text. Two branches that judge the same code add files rather than
490
+ edit them, so git merges the cache without a conflict. After a run that reads
491
+ every file, adhere prunes the cache: it deletes answers about content no file
492
+ has and answers to rule texts no rule asks, and folds what is left into one
493
+ file per content. A run narrowed by `--filter` does not prune. To keep the
494
+ cache out of diffs, mark it as generated in `.gitattributes`:
495
+
496
+ ```text
497
+ .adhere/cache/** linguist-generated -diff
498
+ ```
499
+
500
+ Anyone who can commit can also write a cache file saying that code passes.
501
+ Where lint gates a merge, lint a pull request against the cache as merged,
502
+ not as the pull request has it; only the files it changes are judged again:
503
+
504
+ ```sh
505
+ rm -rf .adhere/cache && git checkout origin/main -- .adhere/cache
506
+ adhere lint --yes
507
+ ```
368
508
 
369
509
  The API key is read only when a request is about to be sent. A run where
370
510
  every file is cached needs no key and no network.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.7.3",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "description": "Lints a repository against the rules in .adhere/, judged by Jev.",
6
6
  "license": "MIT",
@@ -11,6 +11,12 @@
11
11
  "bin": {
12
12
  "adhere": "bin/adhere.js"
13
13
  },
14
+ "files": [
15
+ "bin",
16
+ "src",
17
+ "presets",
18
+ "skills"
19
+ ],
14
20
  "type": "module",
15
21
  "imports": {
16
22
  "#*": "./src/*"
@@ -18,12 +24,6 @@
18
24
  "exports": {
19
25
  ".": "./src/index.ts"
20
26
  },
21
- "files": [
22
- "bin",
23
- "src",
24
- "presets",
25
- "skills"
26
- ],
27
27
  "publishConfig": {
28
28
  "access": "public"
29
29
  },
@@ -68,10 +68,10 @@
68
68
  }
69
69
  },
70
70
  "optionalDependencies": {
71
- "@drkmttr/adhere-darwin-arm64": "0.7.3",
72
- "@drkmttr/adhere-darwin-x64": "0.7.3",
73
- "@drkmttr/adhere-linux-arm64": "0.7.3",
74
- "@drkmttr/adhere-linux-x64": "0.7.3",
75
- "@drkmttr/adhere-win32-x64": "0.7.3"
71
+ "@drkmttr/adhere-darwin-arm64": "0.9.0",
72
+ "@drkmttr/adhere-darwin-x64": "0.9.0",
73
+ "@drkmttr/adhere-linux-arm64": "0.9.0",
74
+ "@drkmttr/adhere-linux-x64": "0.9.0",
75
+ "@drkmttr/adhere-win32-x64": "0.9.0"
76
76
  }
77
77
  }
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Errors that crossed RPC or a workflow replay must be matched by their tag and data fields, never with instanceof, prototype methods, or object identity, which do not survive serialization, and RPC arguments must be serializable values, never functions or class instances.
3
+ ---
4
+
5
+ ```ts must
6
+ const value = yield* stub.get(key).pipe(
7
+ Effect.catchTag("KeyMissing", (error) => Effect.succeed(`missing: ${error.key}`)),
8
+ );
9
+ ```
10
+
11
+ ```ts never
12
+ const value = yield* stub.get(key).pipe(
13
+ Effect.catchAll((error) => (error instanceof KeyMissing ? Effect.succeed("missing") : Effect.fail(error))),
14
+ );
15
+ ```
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: Input from outside the stack, such as browsers, partners, or public URLs, should be validated with a schema through Effect RPC or HttpApi, while calls between the stack's own runtimes should use schemaless RPC through bindings, and unvalidated payloads should not cross a trust boundary.
3
+ ---
4
+
5
+ ```ts should
6
+ const Tasks = HttpApiGroup.make("tasks").add(
7
+ HttpApiEndpoint.post("create", "/tasks", { payload: Schema.Struct({ title: Schema.String }) }),
8
+ );
9
+ const greeter = yield* Cloudflare.Workers.bindWorker(Greeter); // internal call
10
+ ```
11
+
12
+ ```ts should not
13
+ fetch: Effect.gen(function* () {
14
+ const request = yield* HttpServerRequest.HttpServerRequest;
15
+ const body = (yield* request.json) as { title: string; ownerId: string }; // public URL, no validation
16
+ return yield* store.create(body);
17
+ }),
18
+ ```
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Migrations must be generated and reviewed ahead of time, committed with the schema, and applied from the committed files; a deploy, deploy hook, or Action must never generate migration SQL, and schema changes must never run from an HTTP route.
3
+ ---
4
+
5
+ ```ts must
6
+ // package.json: "db:generate": "drizzle-kit generate" (run locally, then commit ./drizzle)
7
+ const db = yield* Cloudflare.D1.Database("AppDb", { migrations: "./drizzle" });
8
+ ```
9
+
10
+ ```ts never
11
+ // package.json: "deploy": "drizzle-kit generate && alchemy deploy"
12
+ fetch: Effect.gen(function* () {
13
+ if (url.pathname === "/setup") yield* sql`CREATE TABLE users (id text primary key)`;
14
+ }),
15
+ ```
@@ -0,0 +1,14 @@
1
+ ---
2
+ description: A Hyperdrive connection's origin should be the database's direct endpoint, and clients that connect without Hyperdrive should use the provider's pooled endpoint; Hyperdrive should not point at a provider pooler, which stacks two poolers.
3
+ ---
4
+
5
+ ```ts should
6
+ const hyperdrive = yield* Cloudflare.Hyperdrive.Connection("AppHyperdrive", {
7
+ origin: branch.origin,
8
+ dev: branch.pooledOrigin,
9
+ });
10
+ ```
11
+
12
+ ```ts should not
13
+ const hyperdrive = yield* Cloudflare.Hyperdrive.Connection("AppHyperdrive", { origin: role.pooledOrigin });
14
+ ```
@@ -0,0 +1,11 @@
1
+ ---
2
+ description: A migration runner must connect with the database's direct URL, never Hyperdrive's runtime connection string or an Accelerate or pooled URL.
3
+ ---
4
+
5
+ ```ts must
6
+ export default defineConfig({ dialect: "postgresql", dbCredentials: { url: process.env.DIRECT_DATABASE_URL! } });
7
+ ```
8
+
9
+ ```ts never
10
+ export default defineConfig({ dialect: "postgresql", dbCredentials: { url: process.env.ACCELERATE_URL! } });
11
+ ```
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: A database client or migration config must verify the server's TLS certificate, or connect inside the private network, never setting rejectUnauthorized: false or sslmode=no-verify to make a connection work.
3
+ ---
4
+
5
+ ```ts must
6
+ const url = new URL(connection.connectionString);
7
+ url.searchParams.set("sslmode", "verify-full");
8
+ const db = yield* Drizzle.Postgres(url.toString());
9
+ ```
10
+
11
+ ```ts never
12
+ export default defineConfig({
13
+ dialect: "postgresql",
14
+ dbCredentials: { url: process.env.DATABASE_URL!, ssl: { rejectUnauthorized: false } },
15
+ });
16
+ ```
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: SQL must pass values as parameters, through tagged-template interpolation, sql.in, sql.insert, or prepare().bind(), never by string concatenation or a template string handed to an unsafe or raw API.
3
+ ---
4
+
5
+ ```ts must
6
+ const user = yield* sql`SELECT * FROM users WHERE email = ${email}`;
7
+ const row = yield* Effect.promise(() => d1.prepare("SELECT * FROM users WHERE id = ?").bind(id).first());
8
+ ```
9
+
10
+ ```ts never
11
+ const user = yield* sql.unsafe(`SELECT * FROM users WHERE email = '${email}'`);
12
+ const row = yield* Effect.promise(() => d1.prepare("SELECT * FROM users WHERE id = " + id).first());
13
+ ```
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: Service code should reach its database through the bound private connection, and should not use the public connection URI or TCP proxy, which is meant for laptops and CI.
3
+ ---
4
+
5
+ ```ts should
6
+ const conn = yield* Railway.ConnectPostgres(Db);
7
+ const db = yield* Drizzle.Postgres(conn.connectionString);
8
+ ```
9
+
10
+ ```ts should not
11
+ const db = yield* Drizzle.Postgres(Db.publicConnectionUri);
12
+ ```
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Each database must have one migration owner: when a resource's migrations prop applies the directory, scripts and CI must never also run drizzle-kit migrate or prisma migrate against the same database.
3
+ ---
4
+
5
+ ```ts must
6
+ const branch = yield* Neon.Branch("Main", { project, migrations: "./migrations" });
7
+ // package.json: "deploy": "alchemy deploy"
8
+ ```
9
+
10
+ ```ts never
11
+ const branch = yield* Neon.Branch("Main", { project, migrations: "./migrations" });
12
+ // package.json: "deploy": "drizzle-kit migrate && alchemy deploy"
13
+ ```
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: A handler for events delivered at least once, such as Stripe webhooks, scheduled callbacks, or bucket and queue events, must be idempotent, upserting by a stable ID or deduplicating by event ID, never doing blind inserts, increments, or charges.
3
+ ---
4
+
5
+ ```ts must
6
+ yield* Stripe.consumeEvents([CustomerSubscriptionUpdated], (event) =>
7
+ kv.put(`entitlement:${event.data.object.customer}`, JSON.stringify(entitlementOf(event))),
8
+ );
9
+ ```
10
+
11
+ ```ts never
12
+ yield* Stripe.consumeEvents([InvoicePaid], (event) =>
13
+ sql`UPDATE accounts SET credits = credits + 100 WHERE customer = ${event.data.object.customer}`,
14
+ );
15
+ ```
@@ -0,0 +1,17 @@
1
+ ---
2
+ description: In a Workflow or Durable Function, every side effect, such as an HTTP call, binding call, or write, must run inside a task or step, and code outside steps must be deterministic, never doing I/O or reading the clock or randomness, because the body replays.
3
+ ---
4
+
5
+ ```ts must
6
+ export default Cloudflare.Workflow("Notify", Effect.fn(function* (input: { roomId: string; message: string }) {
7
+ const stored = yield* Cloudflare.Workflows.task("store", kv.put(`notify:${input.roomId}`, input.message));
8
+ yield* Cloudflare.Workflows.sleep("cooldown", "30 seconds");
9
+ return stored;
10
+ }));
11
+ ```
12
+
13
+ ```ts never
14
+ export default Cloudflare.Workflow("Notify", Effect.fn(function* (input: { roomId: string; message: string }) {
15
+ yield* kv.put(`notify:${input.roomId}:${Date.now()}`, input.message); // repeats on every replay
16
+ }));
17
+ ```
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: A bucket-event handler that writes back into the bucket it listens to should filter its subscription by prefix or suffix so its own writes do not match, and should not subscribe to every object created in that bucket.
3
+ ---
4
+
5
+ ```ts should
6
+ yield* AWS.S3.consumeBucketEvents(bucket, { events: ["s3:ObjectCreated:*"], prefix: "incoming/" }, (stream) =>
7
+ stream.pipe(Stream.runForEach((event) => putObject({ Key: `processed/${event.key}`, Body: "..." }))),
8
+ );
9
+ ```
10
+
11
+ ```ts should not
12
+ yield* AWS.S3.consumeBucketEvents(bucket, { events: ["s3:ObjectCreated:*"] }, (stream) =>
13
+ stream.pipe(Stream.runForEach((event) => putObject({ Key: `processed/${event.key}`, Body: "..." }))), // triggers itself
14
+ );
15
+ ```
@@ -0,0 +1,11 @@
1
+ ---
2
+ description: Workflow task, sleep, and waitForEvent names are replay keys, so they must be stable across replays, never built from the clock, randomness, or other values that change between runs.
3
+ ---
4
+
5
+ ```ts must
6
+ yield* Cloudflare.Workflows.task("charge-card", chargeCard(input.orderId));
7
+ ```
8
+
9
+ ```ts never
10
+ yield* Cloudflare.Workflows.task(`charge-${Date.now()}`, chargeCard(input.orderId));
11
+ ```
@@ -0,0 +1,14 @@
1
+ ---
2
+ description: Access to paid features must be gated on state recorded from Stripe webhooks in the app's own store, never granted because the user reached a success_url or return_url.
3
+ ---
4
+
5
+ ```ts must
6
+ const entitlement = yield* kv.get(`entitlement:${customerId}`);
7
+ if (entitlement === null) return HttpServerResponse.empty({ status: 402 });
8
+ ```
9
+
10
+ ```ts never
11
+ if (url.pathname === "/welcome") {
12
+ yield* kv.put(`entitlement:${customerId}`, "active"); // reached the success URL, not paid
13
+ }
14
+ ```
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: A custom provider's diff must check isResolved(news) and return undefined before comparing props that may still be unresolved Outputs, never comparing them directly.
3
+ ---
4
+
5
+ ```ts must
6
+ diff: Effect.fn(function* ({ news, olds }) {
7
+ if (!isResolved(news)) return undefined;
8
+ if (news.region !== olds.region) return { action: "replace" } as const;
9
+ }),
10
+ ```
11
+
12
+ ```ts never
13
+ diff: Effect.fn(function* ({ news, olds }) {
14
+ if (news.region !== olds.region) return { action: "replace" } as const;
15
+ }),
16
+ ```
@@ -0,0 +1,14 @@
1
+ ---
2
+ description: A custom provider's delete must treat a resource that is already gone as success, catching the not-found error, never failing when it runs again.
3
+ ---
4
+
5
+ ```ts must
6
+ delete: ({ output }) =>
7
+ Effect.tryPromise(() => stripe.products.del(output.productId)).pipe(
8
+ Effect.catchIf(isNotFound, () => Effect.void),
9
+ ),
10
+ ```
11
+
12
+ ```ts never
13
+ delete: ({ output }) => Effect.promise(() => stripe.products.del(output.productId)),
14
+ ```
@@ -0,0 +1,14 @@
1
+ ---
2
+ description: A custom provider, credentials, or state-store Layer must defer resolving credentials and opening connections until an operation runs, never resolving credentials, connecting, or prompting while the Layer is built, because every CLI command builds it.
3
+ ---
4
+
5
+ ```ts must
6
+ export const PostgresState = Layer.effect(State, Effect.cached(makePostgresState(props)));
7
+ ```
8
+
9
+ ```ts never
10
+ export const PostgresState = Layer.effect(State, Effect.gen(function* () {
11
+ const sql = postgres(process.env.DATABASE_URL!); // connects on every CLI command
12
+ return makeState(sql);
13
+ }));
14
+ ```
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: A custom provider's reconcile must read the resource's live state and converge it in one flow that serves create, update, and adoption, never branching into separate create and update bodies on output === undefined or trusting olds as proof the resource exists.
3
+ ---
4
+
5
+ ```ts must
6
+ reconcile: Effect.fn(function* ({ news, output }) {
7
+ let live = yield* observe(output);
8
+ if (live === undefined) live = yield* create(news);
9
+ if (drifted(live, news)) live = yield* update(live.id, news);
10
+ return toAttributes(live);
11
+ }),
12
+ ```
13
+
14
+ ```ts never
15
+ reconcile: Effect.fn(function* ({ news, olds, output }) {
16
+ if (output === undefined) return yield* create(news);
17
+ yield* updateTags(output.id, olds.tags, news.tags);
18
+ return output;
19
+ }),
20
+ ```
@@ -0,0 +1,14 @@
1
+ ---
2
+ description: A path prop such as main or rootDir on a resource declared outside the directory alchemy runs from must be anchored to the declaring file with import.meta, never given as a bare relative string, which resolves from the root instead.
3
+ ---
4
+
5
+ ```ts must
6
+ // backend/src/api.ts
7
+ export default Cloudflare.Worker("Api", { main: import.meta.url }, program);
8
+ export const Site = Cloudflare.Website.Vite("Site", { rootDir: path.resolve(import.meta.dirname, "../frontend") });
9
+ ```
10
+
11
+ ```ts never
12
+ // backend/src/api.ts
13
+ export default Cloudflare.Worker("Api", { main: "src/api.ts" }, program); // resolves to <root>/src/api.ts
14
+ ```