@adamaho/nopeus-oxlint-plugin 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 ADDED
@@ -0,0 +1,822 @@
1
+ # @adamaho/nopeus-oxlint-plugin
2
+
3
+ Custom Oxlint rules for AI-assisted TypeScript codebases. The `base` preset
4
+ enables general TypeScript rules; `effect` adds Effect-specific syntax rules.
5
+ Both apply to production and test code.
6
+
7
+ ## Usage
8
+
9
+ Install Nopeus and its Oxlint peer:
10
+
11
+ ```bash
12
+ pnpm add --save-dev @adamaho/nopeus-oxlint-plugin oxlint
13
+ ```
14
+
15
+ Neither preset requires `effect`, `@effect/tsgo`, `@effect/language-service`,
16
+ `@effect/vitest`, or `oxlint-tsgolint` to be installed. Effect packages listed in
17
+ this repository's devDependencies are for developing and testing Nopeus; they
18
+ are not installed with the published plugin.
19
+
20
+ ### General TypeScript rules
21
+
22
+ Use `@adamaho/nopeus-oxlint-plugin/base` in `oxlint.config.ts`:
23
+
24
+ ```ts
25
+ import base from "@adamaho/nopeus-oxlint-plugin/base";
26
+ import { defineConfig } from "oxlint";
27
+
28
+ export default defineConfig({ extends: [base] });
29
+ ```
30
+
31
+ This enables the general rules for type assertions, type widening, dictionaries,
32
+ parameters, reflection, module mocking, conditional spreads, and public JSDoc.
33
+ It does not enable Effect service, runtime, state-lifetime, or v4 migration rules.
34
+ No compiler patch or Effect-specific tsconfig is needed.
35
+
36
+ The separate `@adamaho/nopeus-oxlint-config` package selects built-in Oxlint rules.
37
+ It does not contain the custom rule implementations in this plugin. To combine
38
+ both, install that package too and use `extends: [builtins, base]`, importing
39
+ `builtins` from `@adamaho/nopeus-oxlint-config`.
40
+
41
+ ### Effect syntax rules
42
+
43
+ The `effect` preset includes the general preset, so choose it when the project
44
+ uses Effect; there is no need to extend both plugin presets.
45
+
46
+ Extend the canonical policy from an oxlint.config.ts file:
47
+
48
+ ```ts
49
+ import packageJson from "./package.json" with { type: "json" };
50
+ import effect from "@adamaho/nopeus-oxlint-plugin/effect";
51
+ import { defineConfig } from "oxlint";
52
+
53
+ export default defineConfig({
54
+ extends: [
55
+ effect({
56
+ packageName: packageJson.name,
57
+ runtimeEntryPoints: ["src/main.ts"],
58
+ }),
59
+ ],
60
+ });
61
+ ```
62
+
63
+ The root package name defines the owned Effect service namespace. Both goho and
64
+ @adamaho/goho require trace names and service keys beginning with @goho/.
65
+
66
+ The package publishes compiled ESM and requires Node.js 22.18 or newer, or
67
+ Node.js 24 or newer.
68
+
69
+ ## Rules
70
+
71
+ ### nopeus/no-export-assignment
72
+
73
+ Rejects TypeScript's CommonJS `export = value` syntax; use named ESM exports or
74
+ `export default` instead. Enabled in both `/base` and `/effect`. The built-in
75
+ config supplies `typescript/no-require-imports` and `import/no-commonjs` for
76
+ CommonJS imports and JavaScript exports. The policy requires ESM source, not
77
+ ESM-only dependencies.
78
+
79
+ ### nopeus/require-test-location
80
+
81
+ Test files belong under the nearest package's `test/` directory, alongside
82
+ `src/`, and use `.test` rather than `.spec` before their source extension.
83
+ The same policy applies to libraries and executable programs, regardless of
84
+ whether Oxlint runs from the repository root or a package directory.
85
+
86
+ Bad: `src/users/service.test.ts`, `tests/users.test.ts`, `test/users.spec.ts`.
87
+
88
+ Good: `test/users/service.test.ts`, `test/integration/users.test.ts`,
89
+ `test/e2e/server.test.ts`, `test/package/installation.test.ts`.
90
+
91
+ Module tests should mirror source paths, but the rule does not require a test
92
+ for each source file or prescribe feature folders. It recognizes `.test` and
93
+ `.spec` filenames with JS, JSX, TS, TSX, MJS, CJS, MTS, and CTS extensions;
94
+ it does not infer tests from arbitrary function calls. Files without a package
95
+ owner are skipped. Test helpers need no `.test` suffix.
96
+
97
+ ### nopeus/no-test-imports
98
+
99
+ Files under a package's `src/` cannot import its `test/` resources, another
100
+ package's test resources, or legacy `tests/`, `__tests__`, and test/spec files.
101
+ This includes type-only imports, re-exports, literal dynamic imports, and
102
+ unshadowed `require` calls. Tests and root-level runner configs may use helpers.
103
+
104
+ Bad, in `src/users/service.ts`:
105
+
106
+ ```ts
107
+ import { usersLayer } from "../../test/helpers/users-test-layer.ts";
108
+ ```
109
+
110
+ Good, in `test/users/service.test.ts`:
111
+
112
+ ```ts
113
+ import { make } from "../../src/users/service.ts";
114
+ import { usersLayer } from "../helpers/users-test-layer.ts";
115
+ ```
116
+
117
+ Keep helpers under `test/helpers/` and inputs under `test/fixtures/` when they
118
+ need to be shared. Create those directories only when needed.
119
+
120
+ ### nopeus/no-cross-package-internals
121
+
122
+ Cross-package imports must use the destination package's own name and public
123
+ entrypoints. Relative/absolute filesystem imports and TypeScript aliases cannot
124
+ reach into another package, even when the destination file is itself exported.
125
+ Tests retain access to their own package's private implementation.
126
+
127
+ Bad:
128
+
129
+ ```ts
130
+ import { authenticate } from "../../auth/src/internal/authenticate.ts";
131
+ ```
132
+
133
+ Good:
134
+
135
+ ```ts
136
+ import { authenticate } from "@example/auth";
137
+ import { session } from "@example/auth/session";
138
+ ```
139
+
140
+ Resolution uses `oxc-resolver`, including package export maps, conditional and
141
+ wildcard exports, symlinked workspaces, and automatically discovered tsconfig
142
+ paths. A cross-package alias is accepted only when it spells the destination's
143
+ public package entrypoint and resolves to the same file as that entrypoint.
144
+ Legacy packages without export maps may be imported through their root name;
145
+ deep imports require an explicit export map.
146
+
147
+ Manifests containing only `{"type":"module"}` or `{"type":"commonjs"}`
148
+ select a module format, not a separate package owner. Public exports into these
149
+ directories remain valid; other manifests still establish package boundaries.
150
+
151
+ The import rules inspect static imports/re-exports (including type-only forms),
152
+ TypeScript import types/import-equals, literal dynamic imports, and unshadowed
153
+ `require`. Computed module names, custom bundler-only aliases, and otherwise
154
+ unresolved bare imports remain outside this check; the compiler still owns
155
+ resolution errors. Resolution currently uses `types`, `import`, `node`, and
156
+ `default` conditions. It does not model arbitrary bundler custom conditions.
157
+ CommonJS syntax is rejected separately by the syntax policy; these boundary
158
+ checks do not attempt to resolve `require`-specific export conditions.
159
+
160
+ All three structure rules are errors in both `/base` and `/effect`. Include
161
+ both `src` and `test` in the consuming project's lint command. Narrowly exclude
162
+ deliberately invalid fixture projects, not ordinary test code. The built-in
163
+ config package supplies filename casing separately.
164
+
165
+ ### nopeus/no-module-level-mutable-state
166
+
167
+ Rejects module-level `let`/`var` and direct construction of writable global
168
+ `Map`, `Set`, `WeakMap`, or `WeakSet` values. Service state belongs to construction.
169
+
170
+ Bad:
171
+
172
+ ```ts
173
+ const cache = new Map<UserId, User>();
174
+ export const make = Effect.sync(() => buildUsers(cache));
175
+ ```
176
+
177
+ Good:
178
+
179
+ ```ts
180
+ export const make = Effect.sync(() => {
181
+ const cache = new Map<UserId, User>();
182
+ return buildUsers(cache);
183
+ });
184
+ ```
185
+
186
+ Immutable lookup tables can use an explicit readonly contract:
187
+
188
+ ```ts
189
+ const statusCodes: ReadonlyMap<string, number> = new Map([["ok", 200]]);
190
+ ```
191
+
192
+ `satisfies ReadonlyMap` alone does not remove the mutable methods from an inferred
193
+ Map type and is not an exception. Imported persistent collection constructors
194
+ and function-local state remain allowed. This syntax-level rule does not infer
195
+ arbitrary factories, nested object state, clients, or mutation through aliases.
196
+ Explicitly owned process-wide mutable infrastructure needs a local exception.
197
+
198
+ In v4, separate `Effect.provide` calls can share memoized Layers. Construction
199
+ owns state per acquired service instance, not per provide call. Use `Layer.fresh`
200
+ or `Effect.provide(layer, { local: true })` only where isolation is intentional;
201
+ this rule does not require either mechanism.
202
+
203
+ ### nopeus/require-fetch-abort-signal
204
+
205
+ Requires global `fetch` calls directly inside an `Effect.tryPromise` callback to
206
+ forward that callback's AbortSignal in a visible options object.
207
+
208
+ Bad:
209
+
210
+ ```ts
211
+ Effect.tryPromise({
212
+ try: () => fetch(url),
213
+ catch: toRequestFailed,
214
+ });
215
+ ```
216
+
217
+ Good:
218
+
219
+ ```ts
220
+ Effect.tryPromise({
221
+ try: (signal) => fetch(url, { ...requestOptions, signal }),
222
+ catch: toRequestFailed,
223
+ });
224
+ ```
225
+
226
+ Put `signal` after spreads and computed properties that could overwrite it.
227
+ `globalThis.fetch`, aliased Effect imports, renamed callback parameters, and
228
+ shadowed globals are handled. SDK methods and deferred nested callbacks are
229
+ outside this narrow rule. Prebuilt Request/options objects, combined signals,
230
+ and indirect signal aliases require an explicit local exception or forwarding
231
+ the callback parameter directly at the adapter boundary.
232
+
233
+ ### nopeus/no-type-assertions
234
+
235
+ Rejects every non-const TypeScript assertion. A comment cannot prove a runtime
236
+ invariant, so the policy has no assertion escape hatch.
237
+
238
+ Bad:
239
+
240
+ ```ts
241
+ const user = input as User;
242
+ ```
243
+
244
+ Good:
245
+
246
+ ```ts
247
+ const decodeUser = Schema.decodeUnknownSync(User);
248
+ const user = decodeUser(input);
249
+ ```
250
+
251
+ Effect SQL, Drizzle, and other typed data APIs should carry their result types
252
+ without assertions. Const assertions remain allowed.
253
+
254
+ ### nopeus/no-conditional-empty-object-spread
255
+
256
+ Rejects object spreads that use an empty object as one side of a conditional.
257
+ The omission is easier to review when it is expressed as an explicit mutation
258
+ of an owned object.
259
+
260
+ Bad:
261
+
262
+ ```ts
263
+ const request = {
264
+ url,
265
+ ...(token === undefined ? {} : { token }),
266
+ };
267
+ ```
268
+
269
+ Good:
270
+
271
+ ```ts
272
+ const request: RequestOptions = { url };
273
+ if (token !== undefined) {
274
+ request.token = token;
275
+ }
276
+ ```
277
+
278
+ ### nopeus/no-known-value-widening
279
+
280
+ Rejects explicit broad annotations that discard evidence already present in a
281
+ literal, constructor, function, or stable const binding.
282
+
283
+ Bad:
284
+
285
+ ```ts
286
+ const request: object = {
287
+ method: "GET",
288
+ url,
289
+ };
290
+ ```
291
+
292
+ Good:
293
+
294
+ ```ts
295
+ const request = {
296
+ method: "GET",
297
+ url,
298
+ } satisfies RequestOptions;
299
+ ```
300
+
301
+ Prefer inference or satisfies when the value is already known.
302
+
303
+ ### nopeus/no-module-mocking
304
+
305
+ Rejects Vitest and Jest module mocking. Tests should replace dependencies
306
+ through the same interfaces and Effect Layers used by production code.
307
+
308
+ Bad:
309
+
310
+ ```ts
311
+ vi.mock("./users.ts", () => ({
312
+ findById: vi.fn(),
313
+ }));
314
+ ```
315
+
316
+ Good:
317
+
318
+ ```ts
319
+ const UsersTest = Layer.succeed(Users)({
320
+ findById: () => Effect.succeed(testUser),
321
+ });
322
+
323
+ const program = loadUser("user-1").pipe(Effect.provide(UsersTest));
324
+ ```
325
+
326
+ ### nopeus/no-object-parameters
327
+
328
+ Rejects the broad object type on function parameters, including aliases that
329
+ resolve to object. Inputs need a named owner contract.
330
+
331
+ Bad:
332
+
333
+ ```ts
334
+ function saveUser(user: object) {
335
+ return repository.save(user);
336
+ }
337
+ ```
338
+
339
+ Good:
340
+
341
+ ```ts
342
+ interface SaveUserInput {
343
+ readonly id: UserId;
344
+ readonly name: string;
345
+ }
346
+
347
+ function saveUser(user: SaveUserInput) {
348
+ return repository.save(user);
349
+ }
350
+ ```
351
+
352
+ ### nopeus/no-reflect-apply
353
+
354
+ Rejects Reflect.apply because it bypasses an ordinary typed function call.
355
+
356
+ Bad:
357
+
358
+ ```ts
359
+ const result = Reflect.apply(handler, receiver, argumentsList);
360
+ ```
361
+
362
+ Good:
363
+
364
+ ```ts
365
+ const result = handler.call(receiver, request);
366
+ ```
367
+
368
+ When dispatch is genuinely dynamic, put it behind a named typed interface.
369
+
370
+ ### nopeus/no-reflect-get
371
+
372
+ Rejects Reflect.get because it turns property access into unchecked dynamic
373
+ lookup.
374
+
375
+ Bad:
376
+
377
+ ```ts
378
+ const userId = Reflect.get(payload, "userId");
379
+ ```
380
+
381
+ Good:
382
+
383
+ ```ts
384
+ const payload = Schema.decodeUnknownSync(UserPayload)(input);
385
+ const userId = payload.userId;
386
+ ```
387
+
388
+ ### nopeus/no-runtime-typeof
389
+
390
+ Rejects runtime typeof checks. Primitive narrowing proves only a JavaScript
391
+ representation, not the domain contract expected by the application.
392
+
393
+ Bad:
394
+
395
+ ```ts
396
+ if (typeof input === "string") {
397
+ return input;
398
+ }
399
+ ```
400
+
401
+ Good:
402
+
403
+ ```ts
404
+ const decodeName = Schema.decodeUnknownEffect(Name);
405
+ const name = yield * decodeName(input);
406
+ ```
407
+
408
+ Decode external input at its boundary, then branch on the decoded domain value.
409
+ Explicit type-guard and assertion-function bodies may use typeof because their
410
+ contract makes the narrowing boundary visible.
411
+
412
+ ### nopeus/no-unknown-returns
413
+
414
+ Rejects explicit unknown, Promise of unknown, and local aliases that resolve to
415
+ unknown in function return contracts. Parsing responsibility belongs inside the
416
+ boundary function rather than with every caller.
417
+
418
+ Bad:
419
+
420
+ ```ts
421
+ function parseUser(text: string): unknown {
422
+ return JSON.parse(text);
423
+ }
424
+ ```
425
+
426
+ Good:
427
+
428
+ ```ts
429
+ function parseUser(text: string): User {
430
+ return Schema.decodeUnknownSync(User)(JSON.parse(text));
431
+ }
432
+ ```
433
+
434
+ ### nopeus/no-unknown-type-aliases
435
+
436
+ Rejects aliases that merely hide unknown behind a domain-looking name.
437
+
438
+ Bad:
439
+
440
+ ```ts
441
+ type UserPayload = unknown;
442
+ ```
443
+
444
+ Good:
445
+
446
+ ```ts
447
+ const UserPayload = Schema.Struct({
448
+ id: UserId,
449
+ name: Schema.String,
450
+ });
451
+
452
+ type UserPayload = typeof UserPayload.Type;
453
+ ```
454
+
455
+ Unknown should remain visible at the parsing boundary.
456
+
457
+ ### nopeus/no-unsafe-dictionary-type
458
+
459
+ Rejects dictionaries whose direct value type is unknown, any, object, an empty
460
+ object type, or an alias or union containing one of those escape hatches.
461
+
462
+ Bad:
463
+
464
+ ```ts
465
+ type UsersById = Record<string, unknown>;
466
+ ```
467
+
468
+ Good:
469
+
470
+ ```ts
471
+ type UsersById = Readonly<Record<UserId, User>>;
472
+ ```
473
+
474
+ Use a concrete owner type for dictionary values.
475
+
476
+ ### nopeus/no-effect-runners-in-library
477
+
478
+ Rejects Effect runtime runners outside the files listed in
479
+ `runtimeEntryPoints`. Library modules should return Effects so callers retain
480
+ control of runtime configuration, interruption, and observability. Entrypoints
481
+ are exact repository-relative paths; `src/main.ts` does not allow a nested
482
+ `packages/example/src/main.ts`.
483
+
484
+ Bad outside an entrypoint:
485
+
486
+ ```ts
487
+ export const loadUsers = () => Effect.runPromise(Users.all);
488
+ ```
489
+
490
+ Good:
491
+
492
+ ```ts
493
+ export const loadUsers = Users.all;
494
+
495
+ // src/main.ts, configured as a runtime entrypoint
496
+ Effect.runPromise(loadUsers);
497
+ ```
498
+
499
+ ### nopeus/no-fallible-effect-promise
500
+
501
+ Rejects Effect.promise. The canonical policy treats every external Promise as
502
+ potentially rejecting; Effect.tryPromise keeps rejection in the typed error
503
+ channel instead of turning it into a defect.
504
+
505
+ Bad:
506
+
507
+ ```ts
508
+ const response = Effect.promise(() => fetch(url));
509
+ ```
510
+
511
+ Good:
512
+
513
+ ```ts
514
+ const response = Effect.tryPromise({
515
+ try: () => fetch(url),
516
+ catch: (cause) => new RequestFailed({ cause }),
517
+ });
518
+ ```
519
+
520
+ ### nopeus/no-inline-live-layer
521
+
522
+ Rejects live Layer constructors inside Effect.provide, including effectful and
523
+ context constructors nested in Layer composition. Build stable live Layers at
524
+ module scope and provide them at a composition boundary.
525
+
526
+ Bad:
527
+
528
+ ```ts
529
+ const program = load.pipe(Effect.provide(Layer.effect(Users, makeUsers)));
530
+ ```
531
+
532
+ Good:
533
+
534
+ ```ts
535
+ export const usersLayer = Layer.effect(Users, makeUsers);
536
+
537
+ const program = load.pipe(Effect.provide(usersLayer));
538
+ ```
539
+
540
+ ### nopeus/no-unscoped-fork
541
+
542
+ Rejects Effect.forkDetach. Background work needs an explicit lifetime so
543
+ shutdown and interruption remain structured.
544
+
545
+ Bad:
546
+
547
+ ```ts
548
+ yield * Effect.forkDetach(refreshCache);
549
+ ```
550
+
551
+ Good:
552
+
553
+ ```ts
554
+ yield * Effect.forkScoped(refreshCache);
555
+ ```
556
+
557
+ Use Effect.forkIn when an existing Scope should own the fiber.
558
+
559
+ ### nopeus/no-untyped-effect-errors
560
+
561
+ Rejects primitive values, object literals, and built-in error classes in
562
+ Effect.fail. This is a syntax-level rule: identifiers and custom error classes
563
+ remain valid, while the common ways of erasing domain error information are
564
+ rejected.
565
+
566
+ Bad:
567
+
568
+ ```ts
569
+ yield * Effect.fail("user not found");
570
+ yield * Effect.fail(new Error("user not found"));
571
+ ```
572
+
573
+ Good:
574
+
575
+ ```ts
576
+ class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
577
+ id: UserId,
578
+ }) {}
579
+
580
+ yield * new UserNotFound({ id });
581
+ ```
582
+
583
+ ### nopeus/prefer-effect-platform-services
584
+
585
+ Rejects direct Node platform APIs only when Effect v4 provides a semantic replacement. Diagnostics
586
+ name both the Effect API and the Node provider (or the runtime-provided service) needed to use it.
587
+ Both `node:x` and bare `x` specifiers are recognized.
588
+
589
+ | Node import | Checked value(s) | Effect replacement | Provider |
590
+ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------- |
591
+ | `fs`, `fs/promises` | Every value import (existing behavior) | `FileSystem.FileSystem` | `NodeFileSystem.layer` or `NodeServices.layer` |
592
+ | `path` | Every value import (existing behavior) | `Path.Path` | `NodePath.layer` or `NodeServices.layer` |
593
+ | `child_process` | Every value import (existing behavior) | `ChildProcess` commands and `ChildProcessSpawner.ChildProcessSpawner` | `NodeChildProcessSpawner.layer` or `NodeServices.layer` |
594
+ | `crypto` | `randomUUID` | `Crypto.Crypto.randomUUIDv4` | `NodeCrypto.layer` or `NodeServices.layer` |
595
+ | `crypto` | `randomUUIDv7` | `Crypto.Crypto.randomUUIDv7` | `NodeCrypto.layer` or `NodeServices.layer` |
596
+ | `crypto` | `randomBytes` | `Crypto.Crypto.randomBytes` | `NodeCrypto.layer` or `NodeServices.layer` |
597
+ | `crypto` | `randomInt` | `Crypto.Crypto.randomIntBetween` | `NodeCrypto.layer` or `NodeServices.layer` |
598
+ | `crypto` | `createHash`, `hash`, `subtle.digest`, `webcrypto.subtle.digest` | `Crypto.Crypto.digest` | `NodeCrypto.layer` or `NodeServices.layer` |
599
+ | `url` | `fileURLToPath` | `Path.Path.fromFileUrl` | `NodePath.layer` or `NodeServices.layer` |
600
+ | `url` | `pathToFileURL` | `Path.Path.toFileUrl` | `NodePath.layer` or `NodeServices.layer` |
601
+ | `console` | `assert`, `clear`, `count`, `countReset`, `debug`, `dir`, `dirxml`, `error`, `group`, `groupCollapsed`, `groupEnd`, `info`, `log`, `table`, `time`, `timeEnd`, `timeLog`, `trace`, `warn` | The matching method on `Console.Console` | The runtime-provided `Console.Console` service |
602
+ | `process` | `argv`, `stdin`, `stdout`, `stderr` | `Stdio.Stdio` | `NodeStdio.layer` or `NodeServices.layer` |
603
+ | `process` | `hrtime` | `Clock.Clock.monotonicTimeNanos` | The runtime-provided `Clock.Clock` service |
604
+ | `timers`, `timers/promises` | `setTimeout` | `Effect.sleep` | The runtime-provided `Clock.Clock` service |
605
+ | `timers`, `timers/promises` | `setInterval` | `Effect.repeat` or `Stream.fromEffectSchedule` | The runtime-provided `Clock.Clock` service |
606
+ | `perf_hooks` | `performance.now` | `Clock.Clock.monotonicTimeNanos` | The runtime-provided `Clock.Clock` service |
607
+ | `http`, `https` | `get`, `request` | `HttpClient.get` / `HttpClient.execute` through `HttpClient.HttpClient` | `NodeHttpClient.layerUndici` or `NodeHttpClient.layerNodeHttp` |
608
+ | `http`, `https` | Direct `createServer` use | `HttpServer.HttpServer` for server behavior | `NodeHttpServer.layer` or `NodeHttpServer.layerConfig` |
609
+ | `net` | `connect`, `createConnection` | `Socket.Socket` | `NodeSocket.makeNet` or `NodeSocket.layerNet` |
610
+ | `net` | `createServer` | `SocketServer.SocketServer` | `NodeSocketServer.layer` |
611
+
612
+ Named and aliased imports are checked at the imported symbol. Namespace and default imports are
613
+ checked when a mapped member is accessed, including computed string properties. Type-only imports
614
+ remain allowed because they perform no platform I/O.
615
+
616
+ `node:http` and `node:https` still require a Node server factory at the application boundary. Passing
617
+ `createServer` directly, or calling it inside the lazy factory, as the first argument of
618
+ `NodeHttpServer.layer` or `NodeHttpServer.layerConfig` is allowed. Using it to implement server
619
+ behavior directly is reported.
620
+
621
+ The audit intentionally leaves Node streams, `EventEmitter`, `Buffer`, OS metadata, `util`, TLS,
622
+ DNS, datagrams, HTTP/2, readline/TTY primitives, compression, and worker threads unrestricted.
623
+ Effect exposes adapters or higher-level abstractions for some of these, but not a semantic
624
+ replacement for every use of the imported Node value. The same is true for the remaining process,
625
+ crypto, console, timer, URL, HTTP, and net exports. In particular, `Worker` remains necessary when
626
+ constructing `NodeWorker.layer`, and Node streams remain necessary at `NodeStream` interop
627
+ boundaries.
628
+
629
+ No autofix is offered. These migrations introduce service requirements, Layers, scoped resources,
630
+ or effectful control flow and need application-specific placement.
631
+
632
+ ```ts
633
+ import { Crypto, Effect } from "effect";
634
+
635
+ const id = Effect.gen(function* () {
636
+ const crypto = yield* Crypto.Crypto;
637
+ return yield* crypto.randomUUIDv4;
638
+ });
639
+ ```
640
+
641
+ ### nopeus/prefer-effect-void
642
+
643
+ Rejects Effect.succeed(undefined) and Effect.succeed(void 0). Effect.void is the
644
+ canonical shared value and makes intent immediate.
645
+
646
+ Bad:
647
+
648
+ ```ts
649
+ const done = Effect.succeed(undefined);
650
+ ```
651
+
652
+ Good:
653
+
654
+ ```ts
655
+ const done = Effect.void;
656
+ ```
657
+
658
+ ### nopeus/require-effect-fn-name
659
+
660
+ Requires every Effect.fn call to begin with a static string name. When the
661
+ function has an owning binding or property, the trace name must equal that
662
+ symbol or end with a dot followed by that symbol. This permits both loadUser and
663
+ Users.loadUser while rejecting unrelated names. A variable containing a name is
664
+ not accepted because the trace boundary should be visible at the call.
665
+
666
+ Bad:
667
+
668
+ ```ts
669
+ import * as Effect from "effect/Effect";
670
+
671
+ const loadUser = Effect.fn(function* (id: UserId) {
672
+ return yield* Users.findById(id);
673
+ });
674
+ ```
675
+
676
+ Good:
677
+
678
+ ```ts
679
+ import * as Effect from "effect/Effect";
680
+
681
+ const loadUser = Effect.fn("@goho/Users.loadUser")(function* (id: UserId) {
682
+ return yield* Users.findById(id);
683
+ });
684
+ ```
685
+
686
+ Effect.fnUntraced remains valid when tracing would not add value, particularly
687
+ in library implementations and hot paths.
688
+
689
+ ### nopeus/require-effect-namespace
690
+
691
+ Enforces readable trace names in `@project/Domain.operation` form. The repository
692
+ prefix comes from the root package name: both `goho` and `@adamaho/goho` use
693
+ `@goho/`. Domain segments use PascalCase; the final operation uses camelCase.
694
+ Nested domains are allowed, for example `@goho/Database.Migrations.run`.
695
+
696
+ | APIs | Checked name |
697
+ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
698
+ | `Effect.fn` | Traced operation; `require-effect-fn-name` also checks the owning symbol |
699
+ | `Effect.makeSpan`, `makeSpanScoped`, `useSpan`; `Layer.span` | Span name |
700
+ | `Effect.withSpan`, `withSpanScoped`, `withLogSpan`; `Layer`, `Stream`, `Channel`, `RequestResolver` `.withSpan` | Span name in data-first and data-last calls |
701
+
702
+ ```ts
703
+ import { Context, Effect, Schema } from "effect";
704
+
705
+ class Users extends Context.Service<Users, Users.Service>()("@goho/Users") {}
706
+ class ReadFailed extends Schema.TaggedError<ReadFailed>()("ReadFailed", {}) {}
707
+ const Outcome = Schema.TaggedStruct("Processed", {});
708
+ const load = Effect.fn("@goho/Users.load")(function* () {
709
+ /* ... */
710
+ });
711
+ ```
712
+
713
+ A trace name must be static and include both a domain and an operation.
714
+ `@goho/load`, `@goho/Users`, whitespace, empty segments, and dynamic interpolation
715
+ are rejected. `Effect.fn` names must end in the owning function or property name:
716
+ `load` can use `@goho/Users.load`, but cannot use `@goho/Users.save`.
717
+ Choose domains that identify the work, such as `Receipts.process` or
718
+ `GoogleDrive.listFiles`; avoid repeating the repository name inside the domain.
719
+ Lint checks structure and owner matching; choosing a meaningful domain still
720
+ requires review. `Effect.fnUntraced` remains available for internal helpers.
721
+
722
+ The rule resolves imports from `effect` and direct `effect/Module` paths, including
723
+ aliases and `import * as E from "effect"`. Shadowed local bindings are ignored.
724
+ String literals and templates without interpolation are accepted, including
725
+ `satisfies` and type assertion wrappers. Re-exports and locally assigned aliases
726
+ are not followed. Unstable subpackages are outside the supported API catalogue.
727
+
728
+ Schema identifiers, data/error/request tags, metric names, ordinary values, and
729
+ log messages are not trace names and are not checked by this rule. In particular,
730
+ a trace-policy upgrade must not require changing serialized `_tag` values.
731
+ Existing API schemas and matching code can keep their established tags.
732
+
733
+ When upgrading from 0.5.0, keep or restore your original data/error tags and metric
734
+ names. Add domain/operation structure to trace names that previously had only a
735
+ prefix. Trace renames may require updating trace queries. No automatic fix is
736
+ provided because lint cannot choose meaningful operation names.
737
+
738
+ ### nopeus/require-service-key-prefix
739
+
740
+ Separately enforces static, nonempty repository-prefixed identity keys for
741
+ `Context.Service` and `Context.Reference`, including curried service declarations.
742
+ Both this rule and the trace rule are enabled by the Effect preset using the same
743
+ repository prefix. Service keys need no `.operation` suffix: `@goho/Users` is valid.
744
+ Import aliases and namespace imports are resolved by scope, so local bindings with
745
+ the same name are ignored.
746
+
747
+ ### nopeus/require-service-constructor-names
748
+
749
+ Name exported Layers and Layer factories `layer` or `layerX`, and service
750
+ constructors `make` or `makeX`. The suffix starts with an uppercase letter:
751
+ `layerConfig`, `layerMemory`, and `makeServiceAccount` are valid;
752
+ `authLayer`, `defaultLayer`, `createAuth`, and `make_client` are not.
753
+ Use lowercase `layer` and `make`; uppercase `Layer` names the imported Effect
754
+ module.
755
+
756
+ Neither export requires the other, and suffixes do not need to match. A service
757
+ may expose only a Layer, only a constructor, or both. Constructors may remain
758
+ private. Layer values, parameterized factories, inline construction, config
759
+ wrappers, and composed layers are supported.
760
+
761
+ Bad:
762
+
763
+ ```ts
764
+ export const createAuth = (options: Options) => Service.of(options);
765
+ export const authLayer = (options: Options) => Layer.succeed(Service, createAuth(options));
766
+ ```
767
+
768
+ Good, with a reusable constructor:
769
+
770
+ ```ts
771
+ export const make = (options: Options) => Service.of(options);
772
+ export const layer = (options: Options) => Layer.succeed(Service, make(options));
773
+ ```
774
+
775
+ Good, with inline construction and no separate `make`:
776
+
777
+ ```ts
778
+ export const layer = (options: Options) =>
779
+ Layer.effect(
780
+ Service,
781
+ Effect.gen(function* () {
782
+ const database = yield* Database;
783
+ return Service.of({ find: (id) => database.find(id, options) });
784
+ }),
785
+ );
786
+ ```
787
+
788
+ Good, resolving configuration through the existing constructor:
789
+
790
+ ```ts
791
+ export const make = (options: Options) => Effect.succeed(Service.of(options));
792
+ export const layerConfig = (options: Config.Wrap<Options>) =>
793
+ Layer.effect(Service, Config.unwrap(options).pipe(Effect.flatMap(make)));
794
+ ```
795
+
796
+ Good, composing existing Layers without creating another service:
797
+
798
+ ```ts
799
+ export const layer = (options: Options) =>
800
+ Layer.merge(Auth.layer(options.auth), Database.layer(options.database));
801
+ ```
802
+
803
+ This syntax rule recognizes imported Effect Layer constructors/composition,
804
+ local service `.of` construction, constructors passed to `Layer.effect`,
805
+ `Layer.succeed`, or `Layer.sync`, and explicit Layer/service return types.
806
+ It follows local aliases and returned expressions, including Effect generators
807
+ and functions. Local export aliases are checked by their public name; recognized
808
+ constructors must use named exports rather than a default export.
809
+
810
+ Private helpers and unrelated functions are not subject to this naming rule.
811
+ Opaque imported factories, cross-file re-exports, and arbitrary type inference
812
+ are outside the syntax check. An explicit `Layer.Layer<...>` return annotation
813
+ makes an otherwise opaque Layer factory recognizable. The rule does not verify
814
+ construction behavior or require particular files, interfaces, or export pairs.
815
+
816
+ This replaces `require-service-make-layer`; the old rule is removed, not retained
817
+ as an optional policy. The naming rule is always enabled in the Effect preset.
818
+
819
+ ## Credits
820
+
821
+ The initial policy and rule set were inspired by
822
+ [anti-slop](https://github.com/dmmulroy/anti-slop).