@commonplaces/oxlint-plugin 0.3.1 → 0.5.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 CHANGED
@@ -1,19 +1,22 @@
1
1
  # @commonplaces/oxlint-plugin
2
2
 
3
- 61 custom Oxlint rules for strict Effect TypeScript, Alchemy and general code
4
- checks. This package supports **Oxlint 1.80.0, 1.85.0 or 1.86.0** and **Node 22.14 or newer**.
3
+ 106 custom Oxlint rules for strict Effect TypeScript, Alchemy and general code
4
+ checks. This package supports **Oxlint 1.86.0 or 1.89.0** and **Node 22.14 or newer**.
5
5
  Oxlint's JavaScript plugin API is experimental, so upgrade it only after running
6
- the rule tests. The development tools use 1.80.0; consuming projects may use any supported version.
7
- All three exact versions are checked with independently installed archives. ESLint is not a supported host.
6
+ the rule tests. The development tools use 1.89.0; consuming projects may use any supported version.
7
+ Both exact versions are checked with independently installed archives. ESLint is not a supported host.
8
8
 
9
9
  ## Install
10
10
 
11
- Install the latest npm release and save its exact version:
11
+ Install version 0.5.0 and save its exact version:
12
12
 
13
13
  ```sh
14
- npm install --save-dev --save-exact @commonplaces/oxlint-plugin@latest oxlint@1.80.0
14
+ npm install --save-dev --save-exact @commonplaces/oxlint-plugin@0.5.0 oxlint@1.89.0
15
15
  ```
16
16
 
17
+ Version 0.5.0 adds the optional style preset and the host requirements above.
18
+ Published 0.4.0 keeps its earlier host list. Consumer adoption is a separate step.
19
+
17
20
  The manifest and lockfile retain the resolved version. Historical GitHub release
18
21
  archives remain separate distribution artefacts; a GitHub release does not publish
19
22
  an npm version.
@@ -35,7 +38,18 @@ checks native Alchemy settings and secret sources. Each group turns its checks
35
38
  on as errors. They do not depend on one another. Override individual rules in
36
39
  your own `rules` or `overrides` settings.
37
40
 
38
- The root export contains these three configurations. Individual plugins use
41
+ The root also exports `conventions`, `web` and `typescript`. `conventions` enables
42
+ strict Effect coding conventions; `web` adds React/TanStack, MDX and typography
43
+ checks; `typescript` enables native type-aware safety checks and correctness
44
+ errors. Run the latter with `oxlint --type-aware --deny-warnings .`. These groups
45
+ contain no application paths. Codec and runtime permissions remain explicit
46
+ project facts.
47
+
48
+ `strict` composes all six groups for applications that use the complete stack:
49
+ `defineConfig({ extends: [strict, projectPolicy] })`. It adds no project paths
50
+ or source ignores. Keep exact ownership settings in the consuming project.
51
+
52
+ The root exports these six groups, the optional `style` preset and the `strict` composition. Individual plugins use
39
53
  the subpaths below. For example, a JSON configuration can enable one rule:
40
54
 
41
55
  ```json
@@ -45,10 +59,97 @@ the subpaths below. For example, a JSON configuration can enable one rule:
45
59
  }
46
60
  ```
47
61
 
62
+ ## Optional style preset
63
+
64
+ Add `style` before `strict`, then keep the project's own policy last:
65
+
66
+ ```ts
67
+ import { style, strict } from "@commonplaces/oxlint-plugin";
68
+ import { defineConfig } from "oxlint";
69
+ import project from "./tools/oxlint/project.config.ts";
70
+
71
+ export default defineConfig({ extends: [style, strict, project] });
72
+ ```
73
+
74
+ Run `oxlint --type-aware --deny-warnings .`. `style` also works alone and is
75
+ available as the default export of `@commonplaces/oxlint-plugin/style`.
76
+ It never imports or extends `strict`; `strict` still contains only its original
77
+ six groups. Adding this release without selecting `style` leaves strict checks
78
+ unchanged. This preset includes more than formatting preferences: Ultracite
79
+ also supplies accessibility, test and correctness checks, browser globals,
80
+ shared ignores and test-file overrides. Its ignores can reduce which files are
81
+ checked; keep the project's own file policy explicit.
82
+
83
+ Ultracite **8.0.1** is an exact regular dependency, selected at the owner's
84
+ request to use the latest releases. It requires no separate consumer install. A peer would
85
+ make consumers choose and install a second package, and could make imports of
86
+ the root package fail when it is missing. The dependency also installs
87
+ Ultracite's CLI dependencies; only the three Oxlint configuration modules run
88
+ when importing this preset. No Ultracite CLI, formatter or Effect runtime runs.
89
+
90
+ ### Shared overrides
91
+
92
+ These eleven rules are off in both historical root configurations: Common
93
+ Practice at `3b6beef^` and Site at `dda3820`. File-specific permissions and
94
+ size limits remain in each consumer's project policy.
95
+
96
+ | Rule set to `off` | Reason |
97
+ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
98
+ | `unicorn/no-array-for-each` | Allow Effect's immutable `Array.forEach`; strict collection checks cover native mutation. |
99
+ | `unicorn/no-array-sort` | Allow Effect's immutable `Array.sort`; strict collection checks cover native mutation. |
100
+ | `unicorn/throw-new-error` | Allow the `Schema.TaggedError` factory; `strict-effect/error-constructor-new` checks error construction when strict is selected. |
101
+ | `no-redeclare` | Allow TypeScript overloads and merged declarations. |
102
+ | `react/function-component-definition` | Allow the project's existing component declaration forms. |
103
+ | `sort-keys` | Keep related object fields in meaningful order. |
104
+ | `func-names` | Allow unnamed library callbacks and Effect generators. |
105
+ | `func-style` | Allow function declarations and functions stored in constants. |
106
+ | `no-use-before-define` | Allow declarations to refer to later declarations. |
107
+ | `no-inline-comments` | Allow short explanations beside the code they describe. |
108
+ | `typescript/consistent-return` | Leave return types and Effect generator branches to TypeScript. |
109
+
110
+ ### Supported-host adjustments
111
+
112
+ The imported configurations need two small adjustments; these are separate
113
+ from the eleven shared overrides above:
114
+
115
+ - React's explicit disables for `jsx-a11y/no-autofocus` and
116
+ `react/forward-ref-uses-ref` are omitted. This lets the host's correctness
117
+ settings apply: selecting `strict` keeps both at error level. Merely putting
118
+ `strict` last would not overcome an explicit upstream disable when strict
119
+ enables a rule through its correctness category.
120
+ - Vitest's file override disabling `vitest/valid-title` is also omitted, so
121
+ strict's correctness setting still applies inside test files.
122
+
123
+ The tests install the packed archive on both supported hosts and compare
124
+ every resolved strict rule, including its options, with `[style, strict]`.
125
+ They also check inherited file overrides. These adjustments import no strict
126
+ settings and leave every other upstream setting intact.
127
+
128
+ ### Ultracite 8.0.1 comparison
129
+
130
+ The installed npm archives were compared, including rule options and file
131
+ patterns, rather than relying on release notes. For the three selected presets:
132
+
133
+ | Preset | Rules in 7.12.0 | Rules in 8.0.1 | Change |
134
+ | ------ | --------------: | -------------: | ------------------------------------------------------------- |
135
+ | Core | 536 | 537 | Adds `typescript/no-generated-empty-object-type` as an error. |
136
+ | React | 124 | 124 | `react/jsx-no-target-blank` adds `{ forms: true }`. |
137
+ | Vitest | 74 | 75 | Adds `vitest/consistent-test-it` as an error. |
138
+
139
+ Counts include entries explicitly set to off and entries in file overrides.
140
+ No rule is removed and no other rule severity or option changes. Core's shared
141
+ test overrides and Vitest's test rules also start matching `.mts`, `.cts`,
142
+ `.mjs` and `.cjs` files. Shared ignores, globals and plugin lists are unchanged.
143
+ Both versions contain `no-unmodified-loop-condition` with
144
+ `checkConditionalExpressions: true`; the supported hosts accept that option.
145
+ Ultracite 8 requires Oxlint 1.86 or newer, so 1.80 and 1.85 are no longer
146
+ supported. Its Oxfmt peer requires at least 0.71; repository tooling uses 0.74.0.
147
+ The archive is checked on exactly 1.86.0 and the current 1.89.0.
148
+
48
149
  ## Project settings
49
150
 
50
- Rules that refer to project files, services, schemas or routes are available
51
- but excluded from the groups. Configure them explicitly, for example:
151
+ Rules that refer to project files, schemas or routes are available but excluded
152
+ from the groups. Configure them explicitly, for example:
52
153
 
53
154
  ```ts
54
155
  import { defineConfig } from "oxlint";
@@ -86,8 +187,35 @@ export default defineConfig({
86
187
  ```
87
188
 
88
189
  Use repository-relative paths with `/` separators. Runtime execution is denied
89
- everywhere unless `allowedFiles` grants an entry file. The service tracing rule
90
- requires `serviceNames` and checks names such as `Effect.fn("Orders.create")`.
190
+ everywhere unless `allowedFiles` grants an entry file. Service tracing is enabled
191
+ by `effect` without a service-name inventory. It checks native local
192
+ `Context.Service` constructors and imported capitalised service `.of` calls,
193
+ plus supplied operations in native `Layer.mock` replacements,
194
+ preserving canonical names through named imports, namespace imports and immutable
195
+ aliases. Shadowed names cannot borrow import identity. Every explicit operation
196
+ requires its exact `Effect.fn("Orders.create")` or `Effect.withSpan` name.
197
+ Legacy `serviceNames` selection remains available for existing consumers.
198
+ An empty selector is rejected.
199
+
200
+ Local contract types identify checked data and Stream members; native Stream
201
+ expressions also retain their role in imported mixed contracts. Opaque members
202
+ fail closed rather than gaining a name-based exemption. Instrumentation must
203
+ wrap the operation or its returned Effect; an unrelated nested span is
204
+ insufficient. The rule examines syntax, not a cross-file TypeScript type graph;
205
+ an imported capitalised non-service `.of` API may require a distinct API shape.
206
+ Empty partial mocks contain no supplied operation to instrument. The rule does
207
+ not infer contracts for arbitrary Layer factories or raw `Layer.succeed` data
208
+ objects; retain typed composition and review those boundaries.
209
+
210
+ For a canonical operation adapter that preserves an overloaded service API,
211
+ `operationAdapters` can name its public `module`, exact source `file`, `exports`
212
+ and canonical `operation`. The adapter owns the returned operation's span.
213
+ Prove that guarantee directly against the real adapter, with an uninstrumented
214
+ callback and observed current span. Only that operation and those imported
215
+ exports gain this role; shadowed functions, neighbouring modules and other
216
+ service operations still require their own spans. This is an explicit contract
217
+ owner where cross-file type analysis is unavailable, not a general wrapper
218
+ permission.
91
219
  Invalid settings stop linting. Each rule's exported `meta.schema` describes its
92
220
  accepted options; no marketplace plugin is needed to read or run them.
93
221
 
@@ -98,15 +226,42 @@ package subpaths; keep your existing rule settings.
98
226
 
99
227
  ## Rule exports
100
228
 
101
- | Package subpath | Rule prefix | Rules |
102
- | ------------------- | ------------------- | ----: |
103
- | `/strict-effect` | `strict-effect/` | 29 |
104
- | `/effect` | `effect/` | 9 |
105
- | `/alchemy` | `alchemy-secrets/` | 3 |
106
- | `/bun` | `bun/` | 1 |
107
- | `/package` | `package/` | 3 |
108
- | `/anti-slop` | `anti-slop/` | 15 |
109
- | `/anti-slop-effect` | `anti-slop-effect/` | 1 |
229
+ | Package subpath | Rule prefix | Rules |
230
+ | ------------------- | --------------------- | ----: |
231
+ | `/strict-effect` | `strict-effect/` | 29 |
232
+ | `/effect` | `effect/` | 9 |
233
+ | `/alchemy` | `alchemy-secrets/` | 3 |
234
+ | `/bun` | `bun/` | 1 |
235
+ | `/package` | `package/` | 3 |
236
+ | `/anti-slop` | `anti-slop/` | 15 |
237
+ | `/anti-slop-effect` | `anti-slop-effect/` | 1 |
238
+ | `/conventions` | `effect-conventions/` | 33 |
239
+ | `/web` | `web/` | 12 |
240
+
241
+ ### Explicit codec and runtime roles
242
+
243
+ The three optional codec-placement checks in `/conventions` follow real Schema
244
+ imports, named aliases, destructured exports and immutable aliases, respecting
245
+ local shadowing. Add them through positive scopes that exclude exact ingress,
246
+ egress or synchronous consumer owners. A filename ending in `.ingress.ts` or
247
+ `.egress.ts` grants no permission on its own. `Schema.decodeTo` and `encodeTo`
248
+ declarations remain transformations rather than codec execution.
249
+
250
+ `effect-conventions/scoped-managed-runtime` requires acquisition and the
251
+ matching runtime disposer through `Effect.acquireRelease`. For a host that owns
252
+ an isolate/process lifetime and offers no termination callback, `hostLifetimes`
253
+ may identify its exact top-level `const` by repository-relative `file` and
254
+ `declaration`. Another declaration, nested construction or neighbouring file
255
+ still fails. This role does not permit additional runtime execution files.
256
+ Use the separate runtime execution owner rule as well.
257
+
258
+ `browser-runtime-ownership` takes `runtimeOwners` and `registryOwners`.
259
+ `native-http-client-ownership` takes `browserOwners` and `serverOwners`.
260
+ `bounded-build-memo` takes exact `targets` with `file` and `declaration`.
261
+ These are optional rules in `/conventions`; malformed allowances fail closed.
262
+ The memo check requires immutable bounded includes and `lockfile: true`, and
263
+ rejects excluded dependencies, opaque or overwritten settings and broad parent
264
+ recursion outside `src`. There are no application path defaults.
110
265
 
111
266
  ## Development
112
267
 
@@ -179,7 +334,7 @@ integration checks and `bun run test` for the archive installation
179
334
  and public contract checks.
180
335
 
181
336
  Oxlint owns synchronous rule callbacks and reporting. Pure checks use immutable
182
- Effect collections. The two synchronous option decoders in `effect.ts` are
337
+ Effect collections. The synchronous option decoders in `effect.ts` and `service-instrumentation.ts` are
183
338
  required by Oxlint's `create` callback and throw only for invalid configuration.
184
339
  The `syntax.ts` child adapter selects nodes from Oxlint's already parsed tree
185
340
  using its visitor keys; it is not a decoder for external JSON. Traversal cycle
@@ -0,0 +1 @@
1
+ export declare const boundedBuildMemo: import("@oxlint/plugins").Rule;
@@ -0,0 +1,147 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import * as Array from "effect/Array";
3
+ import * as Function from "effect/Function";
4
+ import * as Option from "effect/Option";
5
+ import * as Predicate from "effect/Predicate";
6
+ import * as Schema from "effect/Schema";
7
+ import { constant as constantDeclaration } from "../native-reference.js";
8
+ import { variableInScope as scopedVariable } from "../syntax.js";
9
+ import { propertyName } from "../syntax.js";
10
+ const FilePath = Schema.NonEmptyString.check(Schema.isPattern(/^(?!\/)(?!.*(?:\*|\\|(?:^|\/)\.\.?\/)).+\.tsx?$/u));
11
+ const Identifier = Schema.NonEmptyString.check(Schema.isPattern(/^[A-Za-z_$][\w$]*$/u));
12
+ const Options = Schema.Struct({
13
+ targets: Schema.NonEmptyArray(Schema.Struct({ file: FilePath, declaration: Identifier })),
14
+ });
15
+ // Only immutable local syntax is inspectable. Imported values and cyclic aliases
16
+ // cannot silently replace the checked memo with an opaque value.
17
+ const localValue = (context, node, visited = []) => {
18
+ if (Array.some((previous) => previous === node)(visited)) {
19
+ return Option.none();
20
+ }
21
+ const next = [...visited, node];
22
+ if (node.type === "TSSatisfiesExpression" || node.type === "TSAsExpression") {
23
+ return localValue(context, node.expression, next);
24
+ }
25
+ if (node.type !== "Identifier") {
26
+ return Option.some(node);
27
+ }
28
+ const scope = context.sourceCode.getScope(node);
29
+ return scopedVariable(scope, node.name).pipe(Option.flatMap(constantDeclaration), Option.flatMap((declaration) => Option.fromNullishOr(declaration.init)), Option.flatMap((initialiser) => localValue(context, initialiser, next)));
30
+ };
31
+ const field = (object, name) => Array.findFirst((property) => property.type === "Property" &&
32
+ !property.computed &&
33
+ Option.contains(propertyName(property.key), name))(object.properties).pipe(Option.filter((property) => property.type === "Property"), Option.map((property) => property.value));
34
+ const boundedPattern = (pattern) => {
35
+ if (pattern.length === 0 ||
36
+ /^(?:\/|!)/u.test(pattern) ||
37
+ /[\\{}[\]]/u.test(pattern) ||
38
+ /(?:^|\/)(?:node_modules|\.git|dist)(?:\/|$)/u.test(pattern)) {
39
+ return false;
40
+ }
41
+ if (!pattern.startsWith("../")) {
42
+ return !pattern.includes("/../");
43
+ }
44
+ const external = pattern.replace(/^(?:\.\.\/)+/u, "");
45
+ const segments = external.split("/");
46
+ if (Array.contains("..")(segments)) {
47
+ return false;
48
+ }
49
+ const recursive = Array.findFirstIndex((segment) => segment.includes("**"))(segments);
50
+ return Option.match(recursive, {
51
+ onNone: () => /\.[A-Za-z0-9]+$/u.test(external),
52
+ onSome: (index) => Function.pipe(segments, Array.take(index), Array.contains("src")),
53
+ });
54
+ };
55
+ const safeMemo = (context, expression) => localValue(context, expression).pipe(Option.exists((memo) => {
56
+ if (memo.type !== "ObjectExpression" ||
57
+ Array.some((property) => property.type !== "Property" || property.computed || property.kind !== "init")(memo.properties)) {
58
+ return false;
59
+ }
60
+ const names = Function.pipe(memo.properties, Array.map((property) => {
61
+ if (property.type === "Property") {
62
+ return propertyName(property.key);
63
+ }
64
+ return Option.none();
65
+ }), Array.getSomes);
66
+ if (Array.dedupe(names).length !== names.length || Array.contains("exclude")(names)) {
67
+ return false;
68
+ }
69
+ const locked = field(memo, "lockfile").pipe(Option.flatMap((value) => localValue(context, value)), Option.exists((value) => value.type === "Literal" && value.value === true));
70
+ const included = field(memo, "include").pipe(Option.flatMap((value) => localValue(context, value)), Option.exists((value) => value.type === "ArrayExpression" &&
71
+ value.elements.length > 0 &&
72
+ Array.every((element) => Option.fromNullishOr(element).pipe(Option.flatMap((item) => localValue(context, item)), Option.exists((item) => item.type === "Literal" &&
73
+ Predicate.isString(item.value) &&
74
+ boundedPattern(item.value))))(value.elements)));
75
+ return locked && included;
76
+ }));
77
+ export const boundedBuildMemo = defineRule({
78
+ meta: {
79
+ type: "problem",
80
+ schema: [
81
+ {
82
+ type: "object",
83
+ properties: {
84
+ targets: {
85
+ type: "array",
86
+ minItems: 1,
87
+ items: {
88
+ type: "object",
89
+ properties: { file: { type: "string" }, declaration: { type: "string" } },
90
+ required: ["file", "declaration"],
91
+ additionalProperties: false,
92
+ },
93
+ },
94
+ },
95
+ required: ["targets"],
96
+ additionalProperties: false,
97
+ },
98
+ ],
99
+ messages: {
100
+ options: "Configure exact build-props files and declaration names.",
101
+ memo: "Keep an inspectable memo with bounded source/file globs, lockfile: true and no exclude override. Parent-directory recursive globs must stay inside src; dependency and build-output trees are forbidden.",
102
+ },
103
+ },
104
+ create(context) {
105
+ const options = Array.head(context.options).pipe(Option.flatMap(Schema.decodeUnknownOption(Options)));
106
+ return {
107
+ Program(program) {
108
+ Option.match(options, {
109
+ onNone: () => {
110
+ context.report({ node: program, messageId: "options" });
111
+ },
112
+ onSome: ({ targets }) => {
113
+ Function.pipe(targets, Array.forEach((target) => {
114
+ if (context.filename.replaceAll("\\", "/") !== `${context.cwd}/${target.file}`) {
115
+ return;
116
+ }
117
+ const declaration = Function.pipe(program.body, Array.flatMap((statement) => {
118
+ if (statement.type === "VariableDeclaration" && statement.kind === "const") {
119
+ return statement.declarations;
120
+ }
121
+ if (statement.type === "ExportNamedDeclaration" &&
122
+ statement.declaration?.type === "VariableDeclaration" &&
123
+ statement.declaration.kind === "const") {
124
+ return statement.declaration.declarations;
125
+ }
126
+ return [];
127
+ }), Array.findFirst((value) => value.id.type === "Identifier" && value.id.name === target.declaration));
128
+ const valid = declaration.pipe(Option.flatMap((value) => Option.fromNullishOr(value.init)), Option.flatMap((value) => localValue(context, value)), Option.exists((value) => value.type === "ObjectExpression" &&
129
+ // Hosting spreads before memo are safe; later spreads or computed keys could override it.
130
+ Array.findFirstIndex((property) => property.type === "Property" &&
131
+ Option.contains(propertyName(property.key), "memo"))(value.properties).pipe(Option.exists((index) => Function.pipe(value.properties, Array.drop(index), Array.every((property) => property.type === "Property" && !property.computed)) &&
132
+ Array.filter((property) => property.type === "Property" &&
133
+ Option.contains(propertyName(property.key), "memo"))(value.properties).length === 1)) &&
134
+ field(value, "memo").pipe(Option.exists((memo) => safeMemo(context, memo)))));
135
+ if (!valid) {
136
+ context.report({
137
+ node: Option.getOrElse(declaration, () => program),
138
+ messageId: "memo",
139
+ });
140
+ }
141
+ }));
142
+ },
143
+ });
144
+ },
145
+ };
146
+ },
147
+ });
@@ -0,0 +1,6 @@
1
+ export declare const codecRules: {
2
+ "no-schema-decoder-outside-ingress": import("@oxlint/plugins").Rule;
3
+ "no-schema-encoder-outside-egress": import("@oxlint/plugins").Rule;
4
+ "no-sync-decoder-outside-consumer": import("@oxlint/plugins").Rule;
5
+ "no-throwing-schema-codec": import("@oxlint/plugins").Rule;
6
+ };
@@ -0,0 +1,31 @@
1
+ import { Option } from "effect";
2
+ import { memberName, report, rule } from "./shared.js";
3
+ const decoder = /^decode(?:Unknown)?(?:Effect|Promise|Sync|Option|Result|Exit|Either|Unknown)?$/u;
4
+ const encoder = /^encode(?:Unknown)?(?:Effect|Promise|Sync|Option|Result|Exit|Either|Unknown)?$/u;
5
+ const synchronous = /(?:Option|Result|Exit|Either)$/u;
6
+ export const codecRules = {
7
+ "no-schema-decoder-outside-ingress": rule("Decode raw input once at an explicitly configured ingress owner.", (context) => ({
8
+ CallExpression(node) {
9
+ if (memberName(context, node.callee, "effect/Schema").pipe(Option.exists((name) => decoder.test(name))))
10
+ report(context, node);
11
+ },
12
+ })),
13
+ "no-schema-encoder-outside-egress": rule("Encode checked values only at an explicitly configured outward boundary.", (context) => ({
14
+ CallExpression(node) {
15
+ if (memberName(context, node.callee, "effect/Schema").pipe(Option.exists((name) => encoder.test(name))))
16
+ report(context, node);
17
+ },
18
+ })),
19
+ "no-sync-decoder-outside-consumer": rule("Non-throwing synchronous decoding belongs to an explicit synchronous host consumer.", (context) => ({
20
+ CallExpression(node) {
21
+ if (memberName(context, node.callee, "effect/Schema").pipe(Option.exists((name) => decoder.test(name) && synchronous.test(name))))
22
+ report(context, node);
23
+ },
24
+ })),
25
+ "no-throwing-schema-codec": rule("Use Effect codecs and typed errors instead of throwing synchronous Schema codecs.", (context) => ({
26
+ CallExpression(node) {
27
+ if (memberName(context, node.callee, "effect/Schema").pipe(Option.exists((name) => name.endsWith("Sync") && !synchronous.test(name))))
28
+ report(context, node);
29
+ },
30
+ })),
31
+ };
@@ -0,0 +1,11 @@
1
+ export declare const contractRules: {
2
+ "branded-identifier-schema": import("@oxlint/plugins").Rule;
3
+ "no-raw-semantic-identifier": import("@oxlint/plugins").Rule;
4
+ "no-generic-client-escape": import("@oxlint/plugins").Rule;
5
+ "no-unknown-service-contract": import("@oxlint/plugins").Rule;
6
+ "no-unknown-tagged-error-payload": import("@oxlint/plugins").Rule;
7
+ "no-host-api-in-contract": import("@oxlint/plugins").Rule;
8
+ "no-layer-export-in-service": import("@oxlint/plugins").Rule;
9
+ "no-defecting-web-crypto": import("@oxlint/plugins").Rule;
10
+ "no-mixed-test-globals": import("@oxlint/plugins").Rule;
11
+ };
@@ -0,0 +1,105 @@
1
+ import { Array, Option } from "effect";
2
+ import { operation } from "../native-reference.js";
3
+ import { propertyName } from "../syntax.js";
4
+ import { contains, memberName, plainStringSchema, report, rule } from "./shared.js";
5
+ const identityNames = [
6
+ "canonicalPath",
7
+ "contentPath",
8
+ "cursor",
9
+ "id",
10
+ "mimeType",
11
+ "operation",
12
+ "slug",
13
+ "uri",
14
+ "uriTemplate",
15
+ "url",
16
+ ];
17
+ const ancestor = (node, accepts) => node.parent !== null && (accepts(node.parent) || ancestor(node.parent, accepts));
18
+ export const contractRules = {
19
+ "branded-identifier-schema": rule("Use the owning branded or literal Schema for identifier fields.", (context) => ({
20
+ Property(node) {
21
+ if (Option.exists(propertyName(node.key), (name) => Array.contains(identityNames, name)) &&
22
+ plainStringSchema(context, node.value))
23
+ report(context, node.value);
24
+ },
25
+ })),
26
+ "no-raw-semantic-identifier": rule("Use the owning branded identifier type instead of string.", (context) => ({
27
+ TSPropertySignature(node) {
28
+ if (Option.exists(propertyName(node.key), (name) => Array.contains(["id", "identifier", "slug"], name.toLowerCase())) &&
29
+ node.typeAnnotation?.typeAnnotation.type === "TSStringKeyword")
30
+ report(context, node);
31
+ },
32
+ })),
33
+ "no-generic-client-escape": rule("Expose named semantic operations instead of a generic client callback.", (context) => {
34
+ const check = (node) => {
35
+ if (Option.exists(propertyName(node.key), (name) => Array.contains(["use", "useClient", "withClient"], name)))
36
+ report(context, node);
37
+ };
38
+ return { TSPropertySignature: check, TSMethodSignature: check };
39
+ }),
40
+ "no-unknown-service-contract": rule("Service inputs and Effect error channels use Schema-derived types and closed tagged failures.", (context) => ({
41
+ TSUnknownKeyword(node) {
42
+ const parent = node.parent;
43
+ if ((parent.type === "TSTypeAnnotation" &&
44
+ ancestor(parent, (value) => value.type === "TSFunctionType" || value.type === "TSMethodSignature")) ||
45
+ (parent.type === "TSTypeParameterInstantiation" &&
46
+ Array.get(parent.params, 1).pipe(Option.exists((value) => value === node)) &&
47
+ parent.parent.type === "TSTypeReference" &&
48
+ parent.parent.typeName.type === "TSQualifiedName" &&
49
+ Option.contains(propertyName(parent.parent.typeName.right), "Effect")))
50
+ report(context, node);
51
+ },
52
+ })),
53
+ "no-unknown-tagged-error-payload": rule("Tagged errors expose safe checked fields rather than Schema.Unknown.", (context) => ({
54
+ Property(node) {
55
+ if (operation(context, node.value, "effect/Schema", "Unknown") &&
56
+ ancestor(node, (value) => {
57
+ const factory = (call) => call.type === "CallExpression" &&
58
+ (Option.exists(memberName(context, call.callee, "effect/Schema"), (name) => name === "TaggedError" || name === "TaggedErrorClass") ||
59
+ factory(call.callee));
60
+ return factory(value);
61
+ }))
62
+ report(context, node.value);
63
+ },
64
+ })),
65
+ "no-host-api-in-contract": rule("Keep service, Schema, error and config contracts portable; acquire host APIs in private adapters.", (context) => ({
66
+ ImportDeclaration(node) {
67
+ if (/(?:^|\/)(?:services?|schemas?|errors|config)\.ts$/u.test(context.filename) &&
68
+ /^(?:node:|bun$|playwright$|lighthouse$|chrome-launcher$|alchemy(?:\/|$)|@cloudflare(?:\/|$)|cloudflare$|@octokit(?:\/|$)|@actions(?:\/|$)|@sentry(?:\/|$)|stripe$|resend$|@aws(?:\/|$)|aws-sdk$)/u.test(node.source.value))
69
+ report(context, node.source);
70
+ },
71
+ })),
72
+ "no-layer-export-in-service": rule("Keep service contracts separate from live and test Layer wiring.", (context) => ({
73
+ ExportNamedDeclaration(node) {
74
+ if (!/(?:^|\/)services?\.ts$/u.test(context.filename) ||
75
+ node.declaration?.type !== "VariableDeclaration")
76
+ return;
77
+ Array.forEach(node.declaration.declarations, (declaration) => {
78
+ if (declaration.id.type === "Identifier" &&
79
+ /(?:Live|Mock|Test|TestLive)$/u.test(declaration.id.name))
80
+ report(context, declaration.id);
81
+ });
82
+ },
83
+ })),
84
+ "no-defecting-web-crypto": rule("Map crypto.subtle.digest rejection to a typed error at its adapter boundary.", (context) => ({
85
+ CallExpression(node) {
86
+ if (operation(context, node.callee, "effect/Effect", "promise") &&
87
+ contains(context, node, (value) => value.type === "MemberExpression" &&
88
+ Option.contains(propertyName(value.property), "digest") &&
89
+ value.object.type === "MemberExpression" &&
90
+ Option.contains(propertyName(value.object.property), "subtle")))
91
+ report(context, node);
92
+ },
93
+ })),
94
+ "no-mixed-test-globals": rule("Alias Effect's it helper when mixing it with native Vitest globals.", (context) => ({
95
+ Program(node) {
96
+ const imports = Array.filter(node.body, (statement) => statement.type === "ImportDeclaration");
97
+ const imported = (source, names) => Array.some(imports, (statement) => statement.type === "ImportDeclaration" &&
98
+ statement.source.value === source &&
99
+ Array.some(statement.specifiers, (specifier) => specifier.type === "ImportSpecifier" &&
100
+ Option.exists(propertyName(specifier.imported), (name) => Array.contains(names, name) && specifier.local.name === name)));
101
+ if (imported("@effect/vitest", ["it"]) && imported("vitest", ["it", "describe"]))
102
+ report(context, node);
103
+ },
104
+ })),
105
+ };
@@ -0,0 +1,7 @@
1
+ export declare const lifetimeRules: {
2
+ "scoped-managed-runtime": import("@oxlint/plugins").Rule;
3
+ "scoped-web-handler": import("@oxlint/plugins").Rule;
4
+ "no-live-alchemy-lifecycle": import("@oxlint/plugins").Rule;
5
+ "no-node-runtime": import("@oxlint/plugins").Rule;
6
+ "effect-script-entry": import("@oxlint/plugins").Rule;
7
+ };