@drkmttr/adhere 0.8.0 → 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 (58) hide show
  1. package/README.md +33 -6
  2. package/package.json +6 -6
  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/src/cli.ts +3 -1
  54. package/src/config.ts +12 -2
  55. package/src/presets.ts +44 -6
  56. package/src/services/AdhereConfig.ts +5 -3
  57. package/presets/effect/cli/handlers-delegate.md +0 -24
  58. package/presets/effect/data/records-are-schema-classes.md +0 -23
package/README.md CHANGED
@@ -370,12 +370,33 @@ each rule worded otherwise.
370
370
  ### Presets
371
371
 
372
372
  A preset has the shape of a config without `presets`: `rules`, and optionally
373
- `model` and `threshold`. `effect` is the one preset: 19 Markdown rules in
374
- [`presets/effect/`](./presets/effect/), lifted from the
375
- [effect-solutions](https://github.com/kitlangton/effect-solutions) docs and the
376
- [effect/platform](https://effect.website/docs/platform/introduction/) docs.
377
- Name it in the config, or on the command line with `--preset effect`, in which
378
- 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.
379
400
 
380
401
  The preset leaves out conventions a linter checks exactly. Effect's language
381
402
  service, [`@effect/tsgo`](https://github.com/Effect-TS/tsgo) on TypeScript 7,
@@ -405,6 +426,12 @@ options in `tsconfig.json` turn them on:
405
426
  globals, which the old platform rule also ruled out, need a lint rule of their
406
427
  own, such as `no-restricted-globals`.
407
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.
434
+
408
435
  ### Precedence
409
436
 
410
437
  Highest first: `--threshold` on the command line, the config file, presets in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drkmttr/adhere",
3
- "version": "0.8.0",
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",
@@ -68,10 +68,10 @@
68
68
  }
69
69
  },
70
70
  "optionalDependencies": {
71
- "@drkmttr/adhere-darwin-arm64": "0.8.0",
72
- "@drkmttr/adhere-darwin-x64": "0.8.0",
73
- "@drkmttr/adhere-linux-arm64": "0.8.0",
74
- "@drkmttr/adhere-linux-x64": "0.8.0",
75
- "@drkmttr/adhere-win32-x64": "0.8.0"
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
+ ```
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: An Action body and a SQL file listed in importFiles must be safe to run again, using IF NOT EXISTS, ON CONFLICT, or upserts, never one-shot inserts or calls that duplicate data or fail when an interrupted deploy retries them.
3
+ ---
4
+
5
+ ```ts must
6
+ export const Migrate = Alchemy.Action("Migrate", Effect.fn(function* () {
7
+ const db = yield* D1Client;
8
+ yield* db.exec("CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT)");
9
+ yield* db.exec("INSERT INTO users (id, name) VALUES ('admin', 'Admin') ON CONFLICT (id) DO NOTHING");
10
+ }));
11
+ ```
12
+
13
+ ```ts never
14
+ export const Seed = Alchemy.Action("Seed", Effect.fn(function* () {
15
+ const db = yield* D1Client;
16
+ yield* db.exec("CREATE TABLE users (id TEXT, name TEXT)");
17
+ yield* db.exec("INSERT INTO users VALUES ('admin', 'Admin')"); // a second admin on every retry
18
+ }));
19
+ ```
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Preview stages should reference a long-lived database or cluster with ref and create only cheap per-stage pieces such as a branch, and should not provision a whole project or cluster for every preview.
3
+ ---
4
+
5
+ ```ts should
6
+ const project = stage.startsWith("pr-")
7
+ ? yield* Neon.Project.ref("AppDb", { stage: "staging" })
8
+ : yield* Neon.Project("AppDb", { region: "aws-us-east-1" });
9
+ const branch = yield* Neon.Branch("Branch", { project });
10
+ ```
11
+
12
+ ```ts should not
13
+ const project = yield* Neon.Project("AppDb", { region: "aws-us-east-1" }); // a new project per PR
14
+ const branch = yield* Neon.Branch("Branch", { project });
15
+ ```
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: A bucket or database holding user or business data should be retained with RemovalPolicy.retain(), at least in production, and forceDestroy should be set only on buckets whose contents are disposable; irreplaceable data should not depend on the default destroy policy.
3
+ ---
4
+
5
+ ```ts should
6
+ const uploads = yield* Cloudflare.R2.Bucket("Uploads").pipe(RemovalPolicy.retain(stage === "prod"));
7
+ const cache = yield* Cloudflare.R2.Bucket("Cache", { forceDestroy: true });
8
+ ```
9
+
10
+ ```ts should not
11
+ const userUploads = yield* Cloudflare.R2.Bucket("UserUploads", { forceDestroy: true });
12
+ ```
@@ -0,0 +1,22 @@
1
+ ---
2
+ description: A stack deployed from CI or by more than one machine must use a shared state store such as Cloudflare.state() or AWS.state(), never Alchemy.localState(), which lives on one machine's disk where CI runners cannot see it.
3
+ ---
4
+
5
+ ```ts must
6
+ export default Alchemy.Stack("App", { providers: Cloudflare.providers(), state: Cloudflare.state() },
7
+ Effect.gen(function* () {
8
+ const api = yield* Api;
9
+ return { url: api.url };
10
+ }),
11
+ );
12
+ ```
13
+
14
+ ```ts never
15
+ // deployed by .github/workflows/deploy.yml
16
+ export default Alchemy.Stack("App", { providers: Cloudflare.providers(), state: Alchemy.localState() },
17
+ Effect.gen(function* () {
18
+ const api = yield* Api;
19
+ return { url: api.url };
20
+ }),
21
+ );
22
+ ```
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: A resource's logical ID, its first argument, must stay the same from one deploy to the next, never built from timestamps, random values, UUIDs, or commit SHAs, because a new logical ID replaces the resource and deletes the old one with its data.
3
+ ---
4
+
5
+ ```ts must
6
+ const uploads = yield* Cloudflare.R2.Bucket("Uploads");
7
+ const jobs = yield* AWS.SQS.Queue("Jobs");
8
+ ```
9
+
10
+ ```ts never
11
+ const uploads = yield* Cloudflare.R2.Bucket(`Uploads-${Date.now()}`);
12
+ const db = yield* Neon.Project(`Db-${process.env.GITHUB_SHA}`);
13
+ ```
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: In a stack deployed to several stages, a resource whose physical name is global to an account or org should omit name, so alchemy derives one per stage, or derive it from the stage, and should not hardcode a single name every stage shares.
3
+ ---
4
+
5
+ ```ts should
6
+ const traces = yield* Axiom.Dataset("Traces", Stack.useSync(({ stage }) => ({
7
+ name: `${stage}-traces`,
8
+ kind: "otel:traces:v1" as const,
9
+ })));
10
+ const branch = yield* Neon.Branch("Preview", { project });
11
+ ```
12
+
13
+ ```ts should not
14
+ const traces = yield* Axiom.Dataset("Traces", { name: "app-traces", kind: "otel:traces:v1" });
15
+ ```
@@ -0,0 +1,27 @@
1
+ ---
2
+ description: When a runtime is split into a Tag class and a make Layer, other runtimes must import and bind only the Tag, and only the Stack provides the Layer; another Worker or Function must never import or provide that implementation Layer.
3
+ ---
4
+
5
+ ```ts must
6
+ // src/Admin.ts
7
+ import { Api } from "./Api.ts";
8
+
9
+ export default Cloudflare.Worker("Admin", { main: import.meta.url },
10
+ Effect.gen(function* () {
11
+ const api = yield* Cloudflare.Workers.bindWorker(Api);
12
+ return { fetch: Effect.gen(function* () { return yield* api.fetch(yield* HttpServerRequest.HttpServerRequest); }) };
13
+ }),
14
+ );
15
+ ```
16
+
17
+ ```ts never
18
+ // src/Admin.ts
19
+ import ApiLive, { Api } from "./Api.ts";
20
+
21
+ export default Cloudflare.Worker("Admin", { main: import.meta.url },
22
+ Effect.gen(function* () {
23
+ const api = yield* Cloudflare.Workers.bindWorker(Api);
24
+ return { fetch: forwardTo(api) };
25
+ }).pipe(Effect.provide(ApiLive)), // ships a second copy of Api's runtime
26
+ );
27
+ ```
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: A Config default or fallback used by a runtime must be deterministic, because it is evaluated at deploy time and at runtime, never computed from the clock, randomness, or per-process values.
3
+ ---
4
+
5
+ ```ts must
6
+ const port = yield* Config.Number("PORT").pipe(Config.withDefault(3000));
7
+ const region = yield* Config.String("REGION").pipe(Config.withDefault("us-east-1"));
8
+ ```
9
+
10
+ ```ts never
11
+ const instanceId = yield* Config.String("INSTANCE_ID").pipe(Config.withDefault(crypto.randomUUID()));
12
+ const startedAt = yield* Config.String("STARTED_AT").pipe(Config.withDefault(new Date().toISOString()));
13
+ ```
@@ -0,0 +1,27 @@
1
+ ---
2
+ description: Connections, pools, sockets, and anything else with a finalizer must be acquired inside a handler or method, where the event's scope releases them, never at module scope or in a runtime's construction Effect, whose finalizers may never run.
3
+ ---
4
+
5
+ ```ts must
6
+ Effect.gen(function* () {
7
+ const url = yield* Config.Redacted("DATABASE_URL");
8
+ return {
9
+ fetch: Effect.gen(function* () {
10
+ const pool = yield* Effect.acquireRelease(
11
+ Effect.sync(() => new Pool({ connectionString: Redacted.value(url), max: 1 })),
12
+ (pool) => Effect.promise(() => pool.end()),
13
+ );
14
+ const { rows } = yield* Effect.promise(() => pool.query("select id from users"));
15
+ return yield* HttpServerResponse.json(rows);
16
+ }),
17
+ };
18
+ });
19
+ ```
20
+
21
+ ```ts never
22
+ Effect.gen(function* () {
23
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL }); // one pool for the isolate
24
+ yield* Effect.addFinalizer(() => Effect.promise(() => pool.end())); // workerd never runs this
25
+ return { fetch: handlerUsing(pool) };
26
+ });
27
+ ```
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: In a Lambda handler, anything that must not be lost must be written durably, to a queue or a table, inside the handler before it responds, never left to a dangling promise, a forked fiber, or a finalizer, because Lambda freezes the instance after the response.
3
+ ---
4
+
5
+ ```ts must
6
+ fetch: Effect.gen(function* () {
7
+ const event = yield* HttpServerRequest.schemaBodyJson(AuditEvent);
8
+ yield* sendMessage({ MessageBody: JSON.stringify(event) });
9
+ return HttpServerResponse.empty({ status: 202 });
10
+ }),
11
+ ```
12
+
13
+ ```ts never
14
+ fetch: Effect.gen(function* () {
15
+ const event = yield* HttpServerRequest.schemaBodyJson(AuditEvent);
16
+ void fetch(AUDIT_URL, { method: "POST", body: JSON.stringify(event) }); // may never run
17
+ return HttpServerResponse.empty({ status: 202 });
18
+ }),
19
+ ```
@@ -0,0 +1,24 @@
1
+ ---
2
+ description: A runtime's construction Effect runs at deploy time and again at cold start, so it must only build resources, bindings, layers, and handler groups, never do per-request work such as queries, writes, bound calls, or starting consumers.
3
+ ---
4
+
5
+ ```ts must
6
+ Effect.gen(function* () {
7
+ const table = yield* AWS.DynamoDB.Table("Jobs", { partitionKey: "id", attributes: { id: "S" } });
8
+ const putItem = yield* AWS.DynamoDB.PutItem(table);
9
+ return {
10
+ fetch: Effect.gen(function* () {
11
+ yield* putItem({ Item: { id: { S: crypto.randomUUID() } } });
12
+ return HttpServerResponse.empty({ status: 202 });
13
+ }),
14
+ };
15
+ });
16
+ ```
17
+
18
+ ```ts never
19
+ Effect.gen(function* () {
20
+ const putItem = yield* AWS.DynamoDB.PutItem(table);
21
+ yield* putItem({ Item: { id: { S: "seed" } } }); // runs at deploy and on every cold start
22
+ return { fetch: handler };
23
+ });
24
+ ```
@@ -0,0 +1,31 @@
1
+ ---
2
+ description: In a Worker, Function, or Service, Config values and binding factories must be yielded in the outer construction Effect and captured for the handlers, never yielded for the first time inside fetch or another request handler, which does not run at deploy time, so nothing gets bound.
3
+ ---
4
+
5
+ ```ts must
6
+ export default Cloudflare.Worker("Api", { main: import.meta.url },
7
+ Effect.gen(function* () {
8
+ const apiKey = yield* Config.Redacted("API_KEY");
9
+ const bucket = yield* Cloudflare.R2.ReadBucket(Uploads);
10
+ return {
11
+ fetch: Effect.gen(function* () {
12
+ const object = yield* bucket.get("report.json");
13
+ return HttpServerResponse.text(Redacted.value(apiKey).slice(0, 4));
14
+ }),
15
+ };
16
+ }),
17
+ );
18
+ ```
19
+
20
+ ```ts never
21
+ export default Cloudflare.Worker("Api", { main: import.meta.url },
22
+ Effect.gen(function* () {
23
+ return {
24
+ fetch: Effect.gen(function* () {
25
+ const apiKey = yield* Config.Redacted("API_KEY"); // never bound: API_KEY is missing at runtime
26
+ return HttpServerResponse.text("ok");
27
+ }),
28
+ };
29
+ }),
30
+ );
31
+ ```
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: A token the infrastructure owns, such as a webhook secret or bearer token, should be minted once with alchemy's Random or left for alchemy to provision, and should not be a hardcoded literal or regenerated on every deploy with crypto.randomUUID().
3
+ ---
4
+
5
+ ```ts should
6
+ export const WebhookSecret = Random("WebhookSecret");
7
+ const auth = yield* BetterAuth({ basePath: "/api/auth", emailAndPassword: { enabled: true } });
8
+ ```
9
+
10
+ ```ts should not
11
+ const webhookSecret = crypto.randomUUID(); // a new secret on every deploy
12
+ const auth = yield* BetterAuth({ basePath: "/api/auth", secret: Redacted.make("dev-secret") });
13
+ ```
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: A secret, credential, connection string, token, signed URL, or private key must never be logged, returned in an HTTP response, or returned as a stack output; code must expose only names, presence, or non-secret metadata.
3
+ ---
4
+
5
+ ```ts must
6
+ if (url.pathname === "/secret") {
7
+ return yield* HttpServerResponse.json({ name: "API_TOKEN", set: Redacted.value(token).length > 0 });
8
+ }
9
+ return { serial: certificate.serial, expires: certificate.notAfter }; // stack output
10
+ ```
11
+
12
+ ```ts never
13
+ yield* Effect.log(`token=${Redacted.value(token)}`);
14
+ return { databaseUrl: connection.databaseUrl, tlsKey: certificate.privateKey }; // stack output
15
+ ```
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: A value in an env key that a framework inlines into the browser bundle, such as VITE_, NEXT_PUBLIC_, PUBLIC_, or NUXT_PUBLIC_, must be public configuration, never a secret, credential, or connection string, even wrapped in Redacted.
3
+ ---
4
+
5
+ ```ts must
6
+ const site = yield* Cloudflare.Website.Vite("Site", {
7
+ env: { VITE_API_URL: api.url, STRIPE_SECRET_KEY: Config.Redacted("STRIPE_SECRET_KEY") },
8
+ });
9
+ ```
10
+
11
+ ```ts never
12
+ const site = yield* Cloudflare.Website.Vite("Site", {
13
+ env: { VITE_STRIPE_SECRET_KEY: Config.Redacted("STRIPE_SECRET_KEY"), VITE_DATABASE_URL: branch.connectionUri },
14
+ });
15
+ ```
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: A secret should be read with Config.Redacted, passed to resources and env as Redacted, and unwrapped with Redacted.value only at the call that needs the raw string; it should not be read with Config.String or process.env, or unwrapped early into plain variables.
3
+ ---
4
+
5
+ ```ts should
6
+ const apiKey = yield* Config.Redacted("OPENAI_API_KEY");
7
+ return {
8
+ fetch: Effect.gen(function* () {
9
+ const request = HttpClientRequest.post(url).pipe(HttpClientRequest.bearerToken(Redacted.value(apiKey)));
10
+ return yield* client.execute(request);
11
+ }),
12
+ };
13
+ ```
14
+
15
+ ```ts should not
16
+ const apiKey = Redacted.value(yield* Config.Redacted("OPENAI_API_KEY"));
17
+ const webhook = process.env.SLACK_WEBHOOK_URL!;
18
+ return { fetch: handler(apiKey, webhook) };
19
+ ```
@@ -0,0 +1,17 @@
1
+ ---
2
+ description: A Redacted value must be unwrapped with Redacted.value before it is compared, sent, or serialized, never put into a template literal, string concatenation, String(), or JSON, where it becomes the literal text <redacted> and silently fails every check.
3
+ ---
4
+
5
+ ```ts must
6
+ const expected = yield* Config.Redacted("AUTH_TOKEN");
7
+ if (request.headers.authorization !== `Bearer ${Redacted.value(expected)}`) {
8
+ return HttpServerResponse.empty({ status: 401 });
9
+ }
10
+ ```
11
+
12
+ ```ts never
13
+ const expected = yield* Config.Redacted("AUTH_TOKEN");
14
+ if (request.headers.authorization !== `Bearer ${expected}`) { // compares against "Bearer <redacted>"
15
+ return HttpServerResponse.empty({ status: 401 });
16
+ }
17
+ ```
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: A handler behind a public URL, such as a Function URL, workers.dev route, or a Durable Object chosen by a client-supplied name, must authenticate the caller and check its access before doing protected work, never treating CORS, an obscure URL, or a trusted origin as authorization.
3
+ ---
4
+
5
+ ```ts must
6
+ fetch: Effect.gen(function* () {
7
+ const request = yield* HttpServerRequest.HttpServerRequest;
8
+ const user = yield* verifySession(request.headers.authorization);
9
+ if (user === undefined) return HttpServerResponse.empty({ status: 401 });
10
+ return yield* counters.fetch(user.tenantId, request);
11
+ }),
12
+ ```
13
+
14
+ ```ts never
15
+ fetch: Effect.gen(function* () {
16
+ const request = yield* HttpServerRequest.HttpServerRequest;
17
+ const name = /^\/counters\/([^/]+)$/.exec(new URL(request.url).pathname)?.[1] ?? "default";
18
+ return yield* counters.fetch(name, request); // anyone who knows a name can read it
19
+ }),
20
+ ```
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: A sensitive CI value, such as a token, key, password, or connection string, must be stored with GitHub.Secret, never with GitHub.Variable, which is plain text and visible in logs.
3
+ ---
4
+
5
+ ```ts must
6
+ yield* GitHub.Secret("CloudflareToken", { owner, repository, name: "CLOUDFLARE_API_TOKEN", value: token.value });
7
+ yield* GitHub.Variable("AwsRegion", { owner, repository, name: "AWS_REGION", value: "us-east-1" });
8
+ ```
9
+
10
+ ```ts never
11
+ yield* GitHub.Variables({ owner, repository, variables: { CLOUDFLARE_API_TOKEN: token.value } });
12
+ ```
@@ -0,0 +1,14 @@
1
+ ---
2
+ description: The acting user must come from the session or token verified in that request, never from an ID in the request body, query, or headers, and never from a user cached in a process-wide layer.
3
+ ---
4
+
5
+ ```ts must
6
+ const session = yield* auth.getSession();
7
+ if (session === null) return yield* Effect.fail(new Unauthorized());
8
+ const rows = yield* sql`SELECT * FROM uploads WHERE owner_id = ${session.user.id}`;
9
+ ```
10
+
11
+ ```ts never
12
+ const { ownerId } = yield* HttpServerRequest.schemaBodyJson(Schema.Struct({ ownerId: Schema.String }));
13
+ const rows = yield* sql`SELECT * FROM uploads WHERE owner_id = ${ownerId}`;
14
+ ```
@@ -0,0 +1,19 @@
1
+ ---
2
+ description: Auth configuration must keep state, PKCE, origin and CSRF checks, ID-token verification, and email verification enabled, never disable them or merge accounts by email alone to work around an error or a cross-origin setup.
3
+ ---
4
+
5
+ ```ts must
6
+ const auth = yield* BetterAuth({
7
+ trustedOrigins: [siteOrigin],
8
+ emailAndPassword: { enabled: true, requireEmailVerification: true },
9
+ account: { accountLinking: { disableImplicitLinking: true } },
10
+ });
11
+ ```
12
+
13
+ ```ts never
14
+ const auth = yield* BetterAuth({
15
+ trustedOrigins: ["*"],
16
+ advanced: { disableCSRFCheck: true },
17
+ account: { accountLinking: { trustedProviders: ["github", "google", "company"] } },
18
+ });
19
+ ```
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: A runtime should bind the narrowest capability its calls need, a Read or Write binding when it only reads or only writes, and should not bind ReadWrite for one-sided use.
3
+ ---
4
+
5
+ ```ts should
6
+ const bucket = yield* Cloudflare.R2.ReadBucket(Uploads);
7
+ return { fetch: Effect.gen(function* () { return yield* bucket.get("report.json"); }) };
8
+ ```
9
+
10
+ ```ts should not
11
+ const bucket = yield* Cloudflare.R2.ReadWriteBucket(Uploads); // only get is ever called
12
+ return { fetch: Effect.gen(function* () { return yield* bucket.get("report.json"); }) };
13
+ ```
@@ -0,0 +1,24 @@
1
+ ---
2
+ description: Credentials a stack mints for CI should be scoped to what the app deploys, using a GitHub OIDC role pinned to the repository on AWS and a trimmed permission list on Cloudflare, and should not grant AdministratorAccess or unused write scopes.
3
+ ---
4
+
5
+ ```ts should
6
+ const role = yield* AWS.IAM.Role("GitHubDeploy", {
7
+ assumeRolePolicyDocument: { Version: "2012-10-17", Statement: [{
8
+ Effect: "Allow", Principal: { Federated: oidc.openIDConnectProviderArn },
9
+ Action: ["sts:AssumeRoleWithWebIdentity"],
10
+ Condition: { StringLike: { "token.actions.githubusercontent.com:sub": "repo:acme/app:*" } },
11
+ }] },
12
+ policies: [deployPolicy],
13
+ });
14
+ ```
15
+
16
+ ```ts should not
17
+ const role = yield* AWS.IAM.Role("GitHubDeploy", {
18
+ assumeRolePolicyDocument: { Version: "2012-10-17", Statement: [{
19
+ Effect: "Allow", Principal: { Federated: oidc.openIDConnectProviderArn },
20
+ Action: ["sts:AssumeRoleWithWebIdentity"],
21
+ }] },
22
+ managedPolicyArns: ["arn:aws:iam::aws:policy/AdministratorAccess"],
23
+ });
24
+ ```
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Only a null session may be treated as anonymous; a failed session lookup must stay in the error channel or map to a server error, never be collapsed into signed out.
3
+ ---
4
+
5
+ ```ts must
6
+ const session = yield* auth.getSession().pipe(
7
+ Effect.mapError(() => new AuthenticationUnavailable()),
8
+ );
9
+ if (session === null) return yield* Effect.fail(new Unauthorized());
10
+ ```
11
+
12
+ ```ts never
13
+ const session = yield* auth.getSession().pipe(Effect.orElseSucceed(() => null));
14
+ if (session === null) return yield* Effect.fail(new Unauthorized());
15
+ ```
@@ -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, must be made with a timeout and a retry schedule, never bare. Calls through the platform FileSystem and Path services are local and are not in scope.
2
+ description: A call over the network, such as an HTTP request, a database query, or a third-party API, should carry a timeout and a retry schedule, and should not go out bare. A call through a client that already applies both, such as an HttpClient built with a timeout and retryTransient or an SDK with its own retries, has them. Calls through the platform FileSystem and Path services are local and are not in scope.
3
3
  ---
4
4
 
5
- ```ts must
5
+ ```ts should
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(
@@ -12,6 +12,6 @@ const resilientCall = HttpClient.get("https://api.example.com/users").pipe(
12
12
  );
13
13
  ```
14
14
 
15
- ```ts never
15
+ ```ts should not
16
16
  const response = yield* HttpClient.get(`https://api.example.com/users/${id}`);
17
17
  ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A test must supply config with Layer.succeed on the config service, never by setting environment variables or a ConfigProvider.
2
+ description: A test should supply config with Layer.succeed on the config service, and should not set environment variables or a ConfigProvider to do it.
3
3
  ---
4
4
 
5
- ```ts must
5
+ ```ts should
6
6
  Effect.runPromise(
7
7
  program.pipe(
8
8
  Effect.provide(
@@ -15,7 +15,7 @@ Effect.runPromise(
15
15
  );
16
16
  ```
17
17
 
18
- ```ts never
18
+ ```ts should not
19
19
  beforeAll(() => {
20
20
  process.env.DATABASE_URL = "postgres://localhost/test";
21
21
  });
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: A config value with constraints must be read with Config.schema, never mapped and checked by hand.
2
+ description: A config value with constraints must be validated as it is read, with Config.schema or Config.mapOrFail, never checked by hand after reading.
3
3
  ---
4
4
 
5
5
  ```ts must
@@ -12,8 +12,19 @@ const Environment = Schema.Literals(["development", "staging", "production"]);
12
12
  const program = Effect.gen(function* () {
13
13
  const port = yield* Config.schema(Port, "PORT");
14
14
  const env = yield* Config.schema(Environment, "ENV");
15
+ const timeoutMs = yield* Config.int("TIMEOUT_MS").pipe(
16
+ Config.mapOrFail((ms) =>
17
+ ms > 0 && ms <= 60_000
18
+ ? Effect.succeed(ms)
19
+ : Effect.fail(
20
+ new Config.ConfigError(
21
+ new ConfigProvider.SourceError({ message: "TIMEOUT_MS must be 1 to 60000" }),
22
+ ),
23
+ ),
24
+ ),
25
+ );
15
26
 
16
- return { port, env };
27
+ return { port, env, timeoutMs };
17
28
  });
18
29
  ```
19
30
 
@@ -1,5 +1,5 @@
1
1
  ---
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.
2
+ description: Structured variants, alternatives that carry fields, must be Schema.TaggedClass members of a Schema.Union, never hand-written object types joined by a tag field. Simple alternatives without fields may be Schema.Literals.
3
3
  ---
4
4
 
5
5
  ```ts must
@@ -0,0 +1,42 @@
1
+ ---
2
+ description: A service that depends on other services must be a Context.Service built by a Layer that yields those services as it is constructed, never a factory function or class that takes services, clients, or their implementations as arguments.
3
+ ---
4
+
5
+ ```ts must
6
+ class Users extends Context.Service<
7
+ Users,
8
+ {
9
+ readonly find: (id: UserId) => Effect.Effect<User, UserNotFound>;
10
+ }
11
+ >()("@app/Users") {}
12
+
13
+ export const UsersLive = Layer.effect(
14
+ Users,
15
+ Effect.gen(function* () {
16
+ const database = yield* Database;
17
+ const logger = yield* Logger;
18
+ return Users.of({
19
+ find: (id) =>
20
+ Effect.gen(function* () {
21
+ yield* logger.log(`finding ${id}`);
22
+ return yield* database.findUser(id);
23
+ }),
24
+ });
25
+ }),
26
+ );
27
+ ```
28
+
29
+ ```ts never
30
+ export const makeUsers = (database: Database["Service"], logger: Logger["Service"]) => ({
31
+ find: (id: UserId) =>
32
+ Effect.gen(function* () {
33
+ yield* logger.log(`finding ${id}`);
34
+ return yield* database.findUser(id);
35
+ }),
36
+ });
37
+
38
+ export const program = Effect.gen(function* () {
39
+ const users = makeUsers(yield* Database, yield* Logger);
40
+ return yield* users.find(userId);
41
+ });
42
+ ```
@@ -0,0 +1,46 @@
1
+ ---
2
+ description: A service's operations must have no requirements, typed Effect<A, E, never>, with their dependencies acquired when the service's Layer is built, never left in an operation's requirements type, as in Effect<A, E, Database>.
3
+ ---
4
+
5
+ ```ts must
6
+ class Users extends Context.Service<
7
+ Users,
8
+ {
9
+ readonly find: (id: UserId) => Effect.Effect<User, UserNotFound, never>;
10
+ }
11
+ >()("@app/Users") {}
12
+
13
+ export const UsersLive = Layer.effect(
14
+ Users,
15
+ Effect.gen(function* () {
16
+ const database = yield* Database;
17
+ const logger = yield* Logger;
18
+ return Users.of({
19
+ find: (id) =>
20
+ Effect.gen(function* () {
21
+ yield* logger.log(`finding ${id}`);
22
+ return yield* database.findUser(id);
23
+ }),
24
+ });
25
+ }),
26
+ );
27
+ ```
28
+
29
+ ```ts never
30
+ class Users extends Context.Service<
31
+ Users,
32
+ {
33
+ readonly find: (id: UserId) => Effect.Effect<User, UserNotFound, Database | Logger>;
34
+ }
35
+ >()("@app/Users") {}
36
+
37
+ export const UsersLive = Layer.succeed(Users, {
38
+ find: (id) =>
39
+ Effect.gen(function* () {
40
+ const database = yield* Database;
41
+ const logger = yield* Logger;
42
+ yield* logger.log(`finding ${id}`);
43
+ return yield* database.findUser(id);
44
+ }),
45
+ });
46
+ ```
@@ -1,8 +1,8 @@
1
1
  ---
2
- description: A test implementation of a service must be built with Layer.sync or Layer.succeed over in-memory state, never over a real database, network, or file.
2
+ description: A test implementation of a service should be built with Layer.sync or Layer.succeed over in-memory state, and should not reach a real database, network, or file. Integration tests that exercise the real service are not in scope.
3
3
  ---
4
4
 
5
- ```ts must
5
+ ```ts should
6
6
  static readonly testLayer = Layer.sync(Cache, () => {
7
7
  const store = new Map<string, string>()
8
8
 
@@ -13,7 +13,7 @@ static readonly testLayer = Layer.sync(Cache, () => {
13
13
  })
14
14
  ```
15
15
 
16
- ```ts never
16
+ ```ts should not
17
17
  const EmailTest = Layer.effect(
18
18
  Email,
19
19
  Effect.gen(function* () {
package/src/cli.ts CHANGED
@@ -63,7 +63,9 @@ class ContradictionsReported extends Schema.TaggedError<ContradictionsReported>(
63
63
 
64
64
  const preset = Flag.choice("preset", presetNames).pipe(
65
65
  Flag.optional,
66
- Flag.withDescription("Add a built-in rule set. With a preset, .adhere/config.ts is optional."),
66
+ Flag.withDescription(
67
+ "Add a built-in rule set, or one of its topics, such as effect/basics. With a preset, .adhere/config.ts is optional.",
68
+ ),
67
69
  );
68
70
 
69
71
  const threshold = Flag.float("threshold").pipe(
package/src/config.ts CHANGED
@@ -161,11 +161,21 @@ export interface Overrides {
161
161
  readonly rpm?: number;
162
162
  }
163
163
 
164
- /** The presets a run applies, command-line ones first. */
164
+ /**
165
+ * The presets a run applies, command-line ones first, each once. A topic such
166
+ * as `effect/basics` is left out when its whole preset is named too, since
167
+ * the whole preset already holds its rules.
168
+ */
165
169
  export const presetsOf = (
166
170
  config: Pick<AdhereConfig, "presets">,
167
171
  overrides: Overrides,
168
- ): ReadonlyArray<PresetName> => [...(overrides.presets ?? []), ...config.presets];
172
+ ): ReadonlyArray<PresetName> => {
173
+ const named = [...new Set([...(overrides.presets ?? []), ...config.presets])];
174
+ return named.filter((name) => {
175
+ const [whole] = name.split("/");
176
+ return whole === name || !named.some((other) => other === whole);
177
+ });
178
+ };
169
179
 
170
180
  /**
171
181
  * Precedence, highest first: command line, config file, presets in order
package/src/presets.ts CHANGED
@@ -13,10 +13,48 @@ const presetsRoot = isStandaloneExecutable
13
13
  ? new URL("./presets/", import.meta.url)
14
14
  : new URL("../presets/", import.meta.url);
15
15
 
16
- /** Built-in rule sets, each a directory of Markdown rule files in `presets/`. */
17
- export const presets = {
18
- effect: { rules: new URL("effect/", presetsRoot) },
19
- } as const satisfies Record<string, Preset>;
16
+ /**
17
+ * Built-in rule sets, each a directory of Markdown rule files in `presets/`,
18
+ * and the topics each divides into: its subdirectories.
19
+ */
20
+ const topics = {
21
+ effect: ["basics", "config", "data", "errors", "services", "testing"],
22
+ alchemy: ["apis", "data", "durable", "providers", "resources", "runtime", "secrets", "security"],
23
+ } as const;
24
+
25
+ type Topics = typeof topics;
26
+ type WholePreset = keyof Topics;
27
+
28
+ /** A whole preset, `effect`, or one of its topics, `effect/basics`. */
29
+ export type PresetName = { [P in WholePreset]: P | `${P}/${Topics[P][number]}` }[WholePreset];
30
+
31
+ export const presetNames: ReadonlyArray<PresetName> = Object.entries(topics).flatMap(
32
+ ([preset, names]) => [preset, ...names.map((topic) => `${preset}/${topic}`)],
33
+ ) as ReadonlyArray<PresetName>;
34
+
35
+ /** A built-in preset. With `topic`, only the rules under that subdirectory apply. */
36
+ export interface BuiltInPreset extends Preset {
37
+ readonly rules: URL;
38
+ readonly topic?: string;
39
+ }
40
+
41
+ /**
42
+ * A topic's rules keep the ids they have in the whole preset, so
43
+ * `effect/basics` and `effect` judge `basics/gen-for-sequencing` alike and
44
+ * share its cached answers.
45
+ */
46
+ export const presetOf = (name: PresetName): BuiltInPreset => {
47
+ const [preset = name, topic] = name.split("/");
48
+ return {
49
+ rules: new URL(`${preset}/`, presetsRoot),
50
+ ...(topic === undefined ? {} : { topic }),
51
+ };
52
+ };
53
+
54
+ export const presets: Readonly<Record<WholePreset, BuiltInPreset>> = {
55
+ effect: presetOf("effect"),
56
+ alchemy: presetOf("alchemy"),
57
+ };
20
58
 
21
- export const presetNames = ["effect"] as const satisfies ReadonlyArray<keyof typeof presets>;
22
- export type PresetName = (typeof presetNames)[number];
59
+ /** The directory each whole preset reads, for checking `topics` against it. */
60
+ export const topicsOf = (preset: WholePreset): ReadonlyArray<string> => topics[preset];
@@ -11,7 +11,7 @@ import {
11
11
  type ResolvedConfig,
12
12
  resolveConfig,
13
13
  } from "#config.ts";
14
- import { type PresetName, presets } from "#presets.ts";
14
+ import { type PresetName, presetOf } from "#presets.ts";
15
15
  import {
16
16
  applicableRules,
17
17
  globalRuleSet,
@@ -43,8 +43,10 @@ const loadConfigFile = Effect.fn("AdhereConfig.load")(function* (file: string) {
43
43
  });
44
44
 
45
45
  const loadPreset = Effect.fn("AdhereConfig.loadPreset")(function* (name: PresetName, base: string) {
46
- const preset: Preset = presets[name];
47
- const rules = yield* materializeRules(preset.rules, base);
46
+ const { topic, ...preset } = presetOf(name);
47
+ const all = yield* materializeRules(preset.rules, base);
48
+ const rules =
49
+ topic === undefined ? all : Record.filter(all, (_, id) => id.startsWith(`${topic}/`));
48
50
  return [name, { ...preset, rules } satisfies Loaded<Preset>] as const;
49
51
  });
50
52
 
@@ -1,24 +0,0 @@
1
- ---
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
- ---
4
-
5
- ```ts must
6
- const addCommand = Command.make("add", { text }, ({ text }) =>
7
- Effect.gen(function* () {
8
- const repo = yield* TaskRepo;
9
- const task = yield* repo.add(text);
10
- yield* Console.log(`Added task #${task.id}: ${task.text}`);
11
- }),
12
- ).pipe(Command.withDescription("Add a new task"));
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,23 +0,0 @@
1
- ---
2
- description: Domain records must be defined with Schema.Class, never with a plain interface or type alias.
3
- ---
4
-
5
- ```ts must
6
- export class User extends Schema.Class<User>("User")({
7
- id: UserId,
8
- name: Schema.String,
9
- email: Schema.String,
10
- createdAt: Schema.Date,
11
- }) {
12
- get displayName() {
13
- return `${this.name} (${this.email})`;
14
- }
15
- }
16
- ```
17
-
18
- ```ts never
19
- interface Invoice {
20
- readonly id: InvoiceId;
21
- readonly total: Money;
22
- }
23
- ```