@commonplaces/oxlint-plugin 0.3.1 → 0.4.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 +81 -16
- package/dist/conventions/bounded-build-memo.d.ts +1 -0
- package/dist/conventions/bounded-build-memo.js +147 -0
- package/dist/conventions/codecs.d.ts +6 -0
- package/dist/conventions/codecs.js +31 -0
- package/dist/conventions/contracts.d.ts +11 -0
- package/dist/conventions/contracts.js +105 -0
- package/dist/conventions/lifetimes.d.ts +7 -0
- package/dist/conventions/lifetimes.js +179 -0
- package/dist/conventions/ownership.d.ts +2 -0
- package/dist/conventions/ownership.js +86 -0
- package/dist/conventions/shared.d.ts +11 -0
- package/dist/conventions/shared.js +54 -0
- package/dist/conventions/syntax.d.ts +14 -0
- package/dist/conventions/syntax.js +172 -0
- package/dist/conventions.d.ts +2 -0
- package/dist/conventions.js +19 -0
- package/dist/effect.js +3 -86
- package/dist/index.d.ts +123 -0
- package/dist/index.js +44 -0
- package/dist/native-reference.d.ts +6 -0
- package/dist/native-reference.js +9 -7
- package/dist/package.js +1 -1
- package/dist/service-instrumentation.d.ts +1 -0
- package/dist/service-instrumentation.js +238 -0
- package/dist/strict-effect.js +13 -7
- package/dist/web/typography.d.ts +5 -0
- package/dist/web/typography.js +98 -0
- package/dist/web.d.ts +2 -0
- package/dist/web.js +120 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @commonplaces/oxlint-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
106 custom Oxlint rules for strict Effect TypeScript, Alchemy and general code
|
|
4
4
|
checks. This package supports **Oxlint 1.80.0, 1.85.0 or 1.86.0** and **Node 22.14 or newer**.
|
|
5
5
|
Oxlint's JavaScript plugin API is experimental, so upgrade it only after running
|
|
6
6
|
the rule tests. The development tools use 1.80.0; consuming projects may use any supported version.
|
|
@@ -35,7 +35,18 @@ checks native Alchemy settings and secret sources. Each group turns its checks
|
|
|
35
35
|
on as errors. They do not depend on one another. Override individual rules in
|
|
36
36
|
your own `rules` or `overrides` settings.
|
|
37
37
|
|
|
38
|
-
The root
|
|
38
|
+
The root also exports `conventions`, `web` and `typescript`. `conventions` enables
|
|
39
|
+
strict Effect coding conventions; `web` adds React/TanStack, MDX and typography
|
|
40
|
+
checks; `typescript` enables native type-aware safety checks and correctness
|
|
41
|
+
errors. Run the latter with `oxlint --type-aware --deny-warnings .`. These groups
|
|
42
|
+
contain no application paths. Codec and runtime permissions remain explicit
|
|
43
|
+
project facts.
|
|
44
|
+
|
|
45
|
+
`strict` composes all six groups for applications that use the complete stack:
|
|
46
|
+
`defineConfig({ extends: [strict, projectPolicy] })`. It adds no project paths
|
|
47
|
+
or source ignores. Keep exact ownership settings in the consuming project.
|
|
48
|
+
|
|
49
|
+
The root exports these six groups and the `strict` composition. Individual plugins use
|
|
39
50
|
the subpaths below. For example, a JSON configuration can enable one rule:
|
|
40
51
|
|
|
41
52
|
```json
|
|
@@ -47,8 +58,8 @@ the subpaths below. For example, a JSON configuration can enable one rule:
|
|
|
47
58
|
|
|
48
59
|
## Project settings
|
|
49
60
|
|
|
50
|
-
Rules that refer to project files,
|
|
51
|
-
|
|
61
|
+
Rules that refer to project files, schemas or routes are available but excluded
|
|
62
|
+
from the groups. Configure them explicitly, for example:
|
|
52
63
|
|
|
53
64
|
```ts
|
|
54
65
|
import { defineConfig } from "oxlint";
|
|
@@ -86,8 +97,35 @@ export default defineConfig({
|
|
|
86
97
|
```
|
|
87
98
|
|
|
88
99
|
Use repository-relative paths with `/` separators. Runtime execution is denied
|
|
89
|
-
everywhere unless `allowedFiles` grants an entry file.
|
|
90
|
-
|
|
100
|
+
everywhere unless `allowedFiles` grants an entry file. Service tracing is enabled
|
|
101
|
+
by `effect` without a service-name inventory. It checks native local
|
|
102
|
+
`Context.Service` constructors and imported capitalised service `.of` calls,
|
|
103
|
+
plus supplied operations in native `Layer.mock` replacements,
|
|
104
|
+
preserving canonical names through named imports, namespace imports and immutable
|
|
105
|
+
aliases. Shadowed names cannot borrow import identity. Every explicit operation
|
|
106
|
+
requires its exact `Effect.fn("Orders.create")` or `Effect.withSpan` name.
|
|
107
|
+
Legacy `serviceNames` selection remains available for existing consumers.
|
|
108
|
+
An empty selector is rejected.
|
|
109
|
+
|
|
110
|
+
Local contract types identify checked data and Stream members; native Stream
|
|
111
|
+
expressions also retain their role in imported mixed contracts. Opaque members
|
|
112
|
+
fail closed rather than gaining a name-based exemption. Instrumentation must
|
|
113
|
+
wrap the operation or its returned Effect; an unrelated nested span is
|
|
114
|
+
insufficient. The rule examines syntax, not a cross-file TypeScript type graph;
|
|
115
|
+
an imported capitalised non-service `.of` API may require a distinct API shape.
|
|
116
|
+
Empty partial mocks contain no supplied operation to instrument. The rule does
|
|
117
|
+
not infer contracts for arbitrary Layer factories or raw `Layer.succeed` data
|
|
118
|
+
objects; retain typed composition and review those boundaries.
|
|
119
|
+
|
|
120
|
+
For a canonical operation adapter that preserves an overloaded service API,
|
|
121
|
+
`operationAdapters` can name its public `module`, exact source `file`, `exports`
|
|
122
|
+
and canonical `operation`. The adapter owns the returned operation's span.
|
|
123
|
+
Prove that guarantee directly against the real adapter, with an uninstrumented
|
|
124
|
+
callback and observed current span. Only that operation and those imported
|
|
125
|
+
exports gain this role; shadowed functions, neighbouring modules and other
|
|
126
|
+
service operations still require their own spans. This is an explicit contract
|
|
127
|
+
owner where cross-file type analysis is unavailable, not a general wrapper
|
|
128
|
+
permission.
|
|
91
129
|
Invalid settings stop linting. Each rule's exported `meta.schema` describes its
|
|
92
130
|
accepted options; no marketplace plugin is needed to read or run them.
|
|
93
131
|
|
|
@@ -98,15 +136,42 @@ package subpaths; keep your existing rule settings.
|
|
|
98
136
|
|
|
99
137
|
## Rule exports
|
|
100
138
|
|
|
101
|
-
| Package subpath | Rule prefix
|
|
102
|
-
| ------------------- |
|
|
103
|
-
| `/strict-effect` | `strict-effect/`
|
|
104
|
-
| `/effect` | `effect/`
|
|
105
|
-
| `/alchemy` | `alchemy-secrets/`
|
|
106
|
-
| `/bun` | `bun/`
|
|
107
|
-
| `/package` | `package/`
|
|
108
|
-
| `/anti-slop` | `anti-slop/`
|
|
109
|
-
| `/anti-slop-effect` | `anti-slop-effect/`
|
|
139
|
+
| Package subpath | Rule prefix | Rules |
|
|
140
|
+
| ------------------- | --------------------- | ----: |
|
|
141
|
+
| `/strict-effect` | `strict-effect/` | 29 |
|
|
142
|
+
| `/effect` | `effect/` | 9 |
|
|
143
|
+
| `/alchemy` | `alchemy-secrets/` | 3 |
|
|
144
|
+
| `/bun` | `bun/` | 1 |
|
|
145
|
+
| `/package` | `package/` | 3 |
|
|
146
|
+
| `/anti-slop` | `anti-slop/` | 15 |
|
|
147
|
+
| `/anti-slop-effect` | `anti-slop-effect/` | 1 |
|
|
148
|
+
| `/conventions` | `effect-conventions/` | 33 |
|
|
149
|
+
| `/web` | `web/` | 12 |
|
|
150
|
+
|
|
151
|
+
### Explicit codec and runtime roles
|
|
152
|
+
|
|
153
|
+
The three optional codec-placement checks in `/conventions` follow real Schema
|
|
154
|
+
imports, named aliases, destructured exports and immutable aliases, respecting
|
|
155
|
+
local shadowing. Add them through positive scopes that exclude exact ingress,
|
|
156
|
+
egress or synchronous consumer owners. A filename ending in `.ingress.ts` or
|
|
157
|
+
`.egress.ts` grants no permission on its own. `Schema.decodeTo` and `encodeTo`
|
|
158
|
+
declarations remain transformations rather than codec execution.
|
|
159
|
+
|
|
160
|
+
`effect-conventions/scoped-managed-runtime` requires acquisition and the
|
|
161
|
+
matching runtime disposer through `Effect.acquireRelease`. For a host that owns
|
|
162
|
+
an isolate/process lifetime and offers no termination callback, `hostLifetimes`
|
|
163
|
+
may identify its exact top-level `const` by repository-relative `file` and
|
|
164
|
+
`declaration`. Another declaration, nested construction or neighbouring file
|
|
165
|
+
still fails. This role does not permit additional runtime execution files.
|
|
166
|
+
Use the separate runtime execution owner rule as well.
|
|
167
|
+
|
|
168
|
+
`browser-runtime-ownership` takes `runtimeOwners` and `registryOwners`.
|
|
169
|
+
`native-http-client-ownership` takes `browserOwners` and `serverOwners`.
|
|
170
|
+
`bounded-build-memo` takes exact `targets` with `file` and `declaration`.
|
|
171
|
+
These are optional rules in `/conventions`; malformed allowances fail closed.
|
|
172
|
+
The memo check requires immutable bounded includes and `lockfile: true`, and
|
|
173
|
+
rejects excluded dependencies, opaque or overwritten settings and broad parent
|
|
174
|
+
recursion outside `src`. There are no application path defaults.
|
|
110
175
|
|
|
111
176
|
## Development
|
|
112
177
|
|
|
@@ -179,7 +244,7 @@ integration checks and `bun run test` for the archive installation
|
|
|
179
244
|
and public contract checks.
|
|
180
245
|
|
|
181
246
|
Oxlint owns synchronous rule callbacks and reporting. Pure checks use immutable
|
|
182
|
-
Effect collections. The
|
|
247
|
+
Effect collections. The synchronous option decoders in `effect.ts` and `service-instrumentation.ts` are
|
|
183
248
|
required by Oxlint's `create` callback and throw only for invalid configuration.
|
|
184
249
|
The `syntax.ts` child adapter selects nodes from Oxlint's already parsed tree
|
|
185
250
|
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
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { Array, Option, Predicate, Schema } from "effect";
|
|
3
|
+
import { operation, valueOf } from "../native-reference.js";
|
|
4
|
+
import { propertyName } from "../syntax.js";
|
|
5
|
+
import { contains, report, returns, rule, testFile } from "./shared.js";
|
|
6
|
+
const releaseMatches = (context, release, handler) => {
|
|
7
|
+
if (release.type !== "ArrowFunctionExpression" && release.type !== "FunctionExpression")
|
|
8
|
+
return false;
|
|
9
|
+
const parameter = Array.head(release.params);
|
|
10
|
+
const body = Array.head(returns(release));
|
|
11
|
+
if (Option.isNone(parameter) || Option.isNone(body))
|
|
12
|
+
return false;
|
|
13
|
+
if (!handler)
|
|
14
|
+
return (parameter.value.type === "Identifier" &&
|
|
15
|
+
body.value.type === "MemberExpression" &&
|
|
16
|
+
body.value.object.type === "Identifier" &&
|
|
17
|
+
body.value.object.name === parameter.value.name &&
|
|
18
|
+
Option.contains(propertyName(body.value.property), "disposeEffect"));
|
|
19
|
+
if (parameter.value.type !== "ObjectPattern" ||
|
|
20
|
+
body.value.type !== "CallExpression" ||
|
|
21
|
+
!operation(context, body.value.callee, "effect/Effect", "promise"))
|
|
22
|
+
return false;
|
|
23
|
+
const callback = Array.head(body.value.arguments);
|
|
24
|
+
return callback.pipe(Option.exists((argument) => argument.type === "Identifier" &&
|
|
25
|
+
parameter.value.type === "ObjectPattern" &&
|
|
26
|
+
Array.some(parameter.value.properties, (property) => property.type === "Property" &&
|
|
27
|
+
Option.contains(propertyName(property.key), "dispose") &&
|
|
28
|
+
property.value.type === "Identifier" &&
|
|
29
|
+
property.value.name === argument.name)));
|
|
30
|
+
};
|
|
31
|
+
const acquired = (context, node, handler) => {
|
|
32
|
+
const search = (parent) => {
|
|
33
|
+
if (parent === null)
|
|
34
|
+
return false;
|
|
35
|
+
if (parent.type === "CallExpression" &&
|
|
36
|
+
operation(context, parent.callee, "effect/Effect", "acquireRelease")) {
|
|
37
|
+
const acquire = Array.get(parent.arguments, 0).pipe(Option.flatMap((value) => valueOf(context, value)));
|
|
38
|
+
const release = Array.get(parent.arguments, 1).pipe(Option.flatMap((value) => valueOf(context, value)));
|
|
39
|
+
return (acquire.pipe(Option.exists((value) => value.type === "CallExpression" &&
|
|
40
|
+
operation(context, value.callee, "effect/Effect", "sync") &&
|
|
41
|
+
Array.head(value.arguments).pipe(Option.exists((callback) => Array.contains(returns(callback), node))))) && release.pipe(Option.exists((value) => releaseMatches(context, value, handler))));
|
|
42
|
+
}
|
|
43
|
+
return search(parent.parent);
|
|
44
|
+
};
|
|
45
|
+
return search(node.parent);
|
|
46
|
+
};
|
|
47
|
+
const HostLifetimes = Schema.Struct({
|
|
48
|
+
hostLifetimes: Schema.optionalKey(Schema.Array(Schema.Struct({ file: Schema.NonEmptyString, declaration: Schema.NonEmptyString }))),
|
|
49
|
+
});
|
|
50
|
+
const scopedManagedRuntime = defineRule({
|
|
51
|
+
meta: {
|
|
52
|
+
type: "problem",
|
|
53
|
+
schema: [
|
|
54
|
+
{
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
hostLifetimes: {
|
|
58
|
+
type: "array",
|
|
59
|
+
items: {
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: {
|
|
62
|
+
file: { type: "string", minLength: 1 },
|
|
63
|
+
declaration: { type: "string", minLength: 1 },
|
|
64
|
+
},
|
|
65
|
+
required: ["file", "declaration"],
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
additionalProperties: false,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
messages: {
|
|
74
|
+
policy: "Acquire ManagedRuntime with its own disposer, or declare the exact top-level runtime whose lifetime the host owns.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
create(context) {
|
|
78
|
+
const options = Array.head(context.options).pipe(Option.flatMap(Schema.decodeUnknownOption(HostLifetimes)), Option.getOrElse(() => ({ hostLifetimes: [] })));
|
|
79
|
+
return {
|
|
80
|
+
CallExpression(node) {
|
|
81
|
+
if (!operation(context, node.callee, "effect/ManagedRuntime", "make"))
|
|
82
|
+
return;
|
|
83
|
+
const declaration = node.parent;
|
|
84
|
+
const hosted = declaration.type === "VariableDeclarator" &&
|
|
85
|
+
declaration.init === node &&
|
|
86
|
+
declaration.id.type === "Identifier" &&
|
|
87
|
+
declaration.parent.type === "VariableDeclaration" &&
|
|
88
|
+
declaration.parent.kind === "const" &&
|
|
89
|
+
(declaration.parent.parent.type === "Program" ||
|
|
90
|
+
(declaration.parent.parent.type === "ExportNamedDeclaration" &&
|
|
91
|
+
declaration.parent.parent.parent.type === "Program")) &&
|
|
92
|
+
Array.some(options.hostLifetimes ?? [], (owner) => declaration.id.type === "Identifier" &&
|
|
93
|
+
owner.declaration === declaration.id.name &&
|
|
94
|
+
context.filename.replaceAll("\\", "/") ===
|
|
95
|
+
`${context.cwd.replaceAll("\\", "/")}/${owner.file}`);
|
|
96
|
+
if (!hosted && !acquired(context, node, false))
|
|
97
|
+
report(context, node);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
export const lifetimeRules = {
|
|
103
|
+
"scoped-managed-runtime": scopedManagedRuntime,
|
|
104
|
+
"scoped-web-handler": rule("Acquire the web handler with Effect.acquireRelease and register its own dispose callback through Effect.promise.", (context) => ({
|
|
105
|
+
CallExpression(node) {
|
|
106
|
+
if ((operation(context, node.callee, "effect/http/HttpRouter", "toWebHandler") ||
|
|
107
|
+
operation(context, node.callee, "effect/unstable/http/HttpRouter", "toWebHandler")) &&
|
|
108
|
+
!acquired(context, node, true))
|
|
109
|
+
report(context, node);
|
|
110
|
+
},
|
|
111
|
+
})),
|
|
112
|
+
"no-live-alchemy-lifecycle": rule("Use the native Alchemy CLI for live lifecycle operations; Plan.make/apply and Test/Core are test-only.", (context) => testFile(context.filename)
|
|
113
|
+
? {}
|
|
114
|
+
: {
|
|
115
|
+
ImportDeclaration(node) {
|
|
116
|
+
if (node.source.value === "alchemy/Test/Core")
|
|
117
|
+
report(context, node);
|
|
118
|
+
},
|
|
119
|
+
CallExpression(node) {
|
|
120
|
+
if (operation(context, node.callee, "alchemy", "apply") ||
|
|
121
|
+
operation(context, node.callee, "alchemy/Plan", "make"))
|
|
122
|
+
report(context, node);
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
"no-node-runtime": rule("Use the application's configured Bun runtime owner rather than NodeRuntime.", (context) => ({
|
|
126
|
+
ImportDeclaration(node) {
|
|
127
|
+
if (/^(?:@effect|effect)\/platform-node(?:\/NodeRuntime)?$/u.test(node.source.value) &&
|
|
128
|
+
(node.source.value.endsWith("/NodeRuntime") ||
|
|
129
|
+
Array.some(node.specifiers, (specifier) => specifier.type === "ImportSpecifier" &&
|
|
130
|
+
Option.contains(propertyName(specifier.imported), "NodeRuntime"))))
|
|
131
|
+
report(context, node);
|
|
132
|
+
},
|
|
133
|
+
CallExpression(node) {
|
|
134
|
+
if (operation(context, node.callee, "effect/platform-node/NodeRuntime", "runMain"))
|
|
135
|
+
report(context, node);
|
|
136
|
+
},
|
|
137
|
+
})),
|
|
138
|
+
"effect-script-entry": rule("Repository scripts use TypeScript and the native Bun Effect runtime; Effect tests own test execution.", (context) => ({
|
|
139
|
+
Literal(node) {
|
|
140
|
+
if (/(?:^|\/)scripts\//u.test(context.filename) &&
|
|
141
|
+
Predicate.isString(node.value) &&
|
|
142
|
+
/(?:^|\s)node\s+(?:--test\b|scripts\/)/u.test(node.value))
|
|
143
|
+
report(context, node);
|
|
144
|
+
},
|
|
145
|
+
Program(node) {
|
|
146
|
+
if (!/(?:^|\/)scripts\//u.test(context.filename))
|
|
147
|
+
return;
|
|
148
|
+
if (context.filename.endsWith(".mjs")) {
|
|
149
|
+
report(context, node);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!context.filename.endsWith(".ts"))
|
|
153
|
+
return;
|
|
154
|
+
const imports = Array.filter(node.body, (statement) => statement.type === "ImportDeclaration");
|
|
155
|
+
const effect = Array.some(imports, (statement) => statement.type === "ImportDeclaration" &&
|
|
156
|
+
/^effect(?:\/|$)/u.test(statement.source.value));
|
|
157
|
+
const test = Array.some(imports, (statement) => statement.type === "ImportDeclaration" &&
|
|
158
|
+
(statement.source.value === "@effect/vitest" || statement.source.value === "bun:test"));
|
|
159
|
+
const runtime = Array.some(imports, (statement) => statement.type === "ImportDeclaration" &&
|
|
160
|
+
statement.source.value === "@effect/platform-bun/BunRuntime");
|
|
161
|
+
const launches = contains(context, node, (value) => value.type === "CallExpression" &&
|
|
162
|
+
operation(context, value.callee, "@effect/platform-bun/BunRuntime", "runMain"));
|
|
163
|
+
if (!effect || (testFile(context.filename) ? !test : !runtime || !launches))
|
|
164
|
+
report(context, node);
|
|
165
|
+
},
|
|
166
|
+
ImportDeclaration(node) {
|
|
167
|
+
if (/(?:^|\/)scripts\//u.test(context.filename) &&
|
|
168
|
+
node.source.value === "node:child_process")
|
|
169
|
+
report(context, node);
|
|
170
|
+
},
|
|
171
|
+
MemberExpression(node) {
|
|
172
|
+
if (/(?:^|\/)scripts\//u.test(context.filename) &&
|
|
173
|
+
node.object.type === "Identifier" &&
|
|
174
|
+
node.object.name === "process" &&
|
|
175
|
+
Option.contains(propertyName(node.property), "execPath"))
|
|
176
|
+
report(context, node);
|
|
177
|
+
},
|
|
178
|
+
})),
|
|
179
|
+
};
|