@commonplaces/oxlint-plugin 0.1.2 → 0.3.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 +139 -8
- package/dist/alchemy.js +2 -91
- package/dist/config.d.ts +1 -0
- package/dist/config.js +204 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/native-reference.d.ts +5 -0
- package/dist/native-reference.js +87 -0
- package/dist/package/workspace-import-boundaries.d.ts +1 -0
- package/dist/package/workspace-import-boundaries.js +277 -0
- package/dist/package.d.ts +2 -0
- package/dist/package.js +4 -0
- package/dist/strict-effect.js +171 -3
- package/dist/subprocess-capture-dataflow.d.ts +10 -0
- package/dist/subprocess-capture-dataflow.js +56 -0
- package/dist/subprocess-pipe-capture.d.ts +1 -0
- package/dist/subprocess-pipe-capture.js +219 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
# @commonplaces/oxlint-plugin
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
checks. This package supports **Oxlint 1.80.0 or 1.
|
|
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**.
|
|
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
|
|
7
|
-
|
|
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.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
Install
|
|
11
|
+
Install the checked GitHub release archive:
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
|
-
npm install --save-dev --save-exact
|
|
14
|
+
npm install --save-dev --save-exact https://github.com/crcorbett/commonplace-lint/releases/download/v0.3.0/commonplaces-oxlint-plugin-0.3.0.tgz oxlint@1.80.0
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
The npm registry currently provides `@commonplaces/oxlint-plugin@0.1.2` (57 rules).
|
|
18
|
+
The 0.3.0 additions below require the GitHub archive until a separate npm release
|
|
19
|
+
is published. GitHub archive publication does not publish a registry version.
|
|
20
|
+
|
|
17
21
|
Create `oxlint.config.ts`:
|
|
18
22
|
|
|
19
23
|
```ts
|
|
@@ -96,16 +100,72 @@ package subpaths; keep your existing rule settings.
|
|
|
96
100
|
|
|
97
101
|
| Package subpath | Rule prefix | Rules |
|
|
98
102
|
| ------------------- | ------------------- | ----: |
|
|
99
|
-
| `/strict-effect` | `strict-effect/` |
|
|
103
|
+
| `/strict-effect` | `strict-effect/` | 29 |
|
|
100
104
|
| `/effect` | `effect/` | 9 |
|
|
101
105
|
| `/alchemy` | `alchemy-secrets/` | 3 |
|
|
102
106
|
| `/bun` | `bun/` | 1 |
|
|
103
|
-
| `/package` | `package/` |
|
|
107
|
+
| `/package` | `package/` | 3 |
|
|
104
108
|
| `/anti-slop` | `anti-slop/` | 15 |
|
|
105
109
|
| `/anti-slop-effect` | `anti-slop-effect/` | 1 |
|
|
106
110
|
|
|
107
111
|
## Development
|
|
108
112
|
|
|
113
|
+
### Workspace import boundaries
|
|
114
|
+
|
|
115
|
+
Enable `package/workspace-import-boundaries` explicitly. Supply options derived
|
|
116
|
+
from checked, current workspace manifests at configuration ingress; the rule
|
|
117
|
+
does not read the filesystem or keep its own project inventory.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
"package/workspace-import-boundaries": ["error", {
|
|
121
|
+
owners: [
|
|
122
|
+
{ id: "root", root: "." },
|
|
123
|
+
{ id: "website", root: "apps/website" },
|
|
124
|
+
{ id: "orders", root: "packages/orders" },
|
|
125
|
+
],
|
|
126
|
+
workspaces: [{
|
|
127
|
+
name: "@example/orders",
|
|
128
|
+
owner: "orders",
|
|
129
|
+
exports: { ".": false, "./service": true, "./features/*": true, "./features/private/*": false },
|
|
130
|
+
}],
|
|
131
|
+
workspacePrefixes: ["@example/"],
|
|
132
|
+
forbiddenEdges: [{ from: "website", to: "orders" }],
|
|
133
|
+
exceptions: [{ fromFile: "apps/website/src/approved.ts", toModule: "@example/orders/service" }],
|
|
134
|
+
aliases: [{ prefix: "@website/", root: "apps/website/src" }],
|
|
135
|
+
}]
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The example is illustrative; derive the actual owners, package names and export
|
|
139
|
+
facts from manifests. Export booleans record reachability under the consumer's
|
|
140
|
+
selected conditions. Preserve explicit `null` fences as `false`. Exact exports
|
|
141
|
+
beat single-star patterns, and the most specific matching pattern wins. The
|
|
142
|
+
longest anchored root identifies an owner, including root composition files.
|
|
143
|
+
Cross-owner filesystem imports are rejected even when they reach a workspace
|
|
144
|
+
root or a non-`src` file. Exact exceptions can permit an owner edge, but cannot
|
|
145
|
+
expose a private export or permit a cross-owner filesystem path.
|
|
146
|
+
|
|
147
|
+
The check covers static imports, re-exports, type imports, import-equals,
|
|
148
|
+
unshadowed `require` and dynamic addresses resolvable through immutable local
|
|
149
|
+
strings. Unresolved computed addresses are outside its proof. Explicit aliases
|
|
150
|
+
are bounded directory prefixes; this is not a TypeScript, bundler, conditional
|
|
151
|
+
export or symlink resolver. Same-owner asset queries are allowed; `file:` imports
|
|
152
|
+
are unsupported and rejected. Invalid or contradictory options fail closed.
|
|
153
|
+
|
|
154
|
+
### Subprocess pipe capture
|
|
155
|
+
|
|
156
|
+
The `effect` group enables `strict-effect/subprocess-pipe-capture`. It follows
|
|
157
|
+
native Effect process handles and rejects statically identified collection that
|
|
158
|
+
can wait for a child or one pipe while another captured pipe remains undrained.
|
|
159
|
+
Drain stdout, stderr and exit completion in one scope, for example with
|
|
160
|
+
`Effect.all({ stdout: read(handle.stdout), stderr: read(handle.stderr),
|
|
161
|
+
exitCode: handle.exitCode }, { concurrency: "unbounded" })`.
|
|
162
|
+
|
|
163
|
+
The rule accounts for known job order and concurrency, and accepts known
|
|
164
|
+
non-piped channels. It is bounded syntax analysis, not proof against every
|
|
165
|
+
process deadlock: computed jobs, unknown wrappers and unsupported control flow
|
|
166
|
+
still require review. A small-output test cannot establish safe capture. Test
|
|
167
|
+
both pipes beyond pipe capacity, a nonzero exit, UTF-8 and scoped cleanup.
|
|
168
|
+
|
|
109
169
|
This workspace owns the rule code. Skills provide guidance and installation
|
|
110
170
|
instructions; they do not carry another copy of the rules. The package contains
|
|
111
171
|
compiled JavaScript and declarations, the README and the MIT licence. It does
|
|
@@ -129,3 +189,74 @@ or infrastructure stack inside the package.
|
|
|
129
189
|
|
|
130
190
|
The strict Effect rules were adapted from Cooper's DAW rules at `e47ecfb` and
|
|
131
191
|
subsequent Commonplace rules. The package's MIT licence covers this lint code.
|
|
192
|
+
|
|
193
|
+
## Keep address ownership precise
|
|
194
|
+
|
|
195
|
+
`strict-effect/public-host-literals` can check test setup without replacing
|
|
196
|
+
independent expected values with the implementation under test:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
"strict-effect/public-host-literals": ["error", {
|
|
200
|
+
hosts: ["example.com"],
|
|
201
|
+
allowedFiles: ["packages/platform/src/site-address.ts"],
|
|
202
|
+
allowedDeclarations: [{
|
|
203
|
+
file: "packages/platform/src/site-address.ts",
|
|
204
|
+
names: ["siteHostName"],
|
|
205
|
+
}],
|
|
206
|
+
allowTestFiles: false,
|
|
207
|
+
allowTestAssertions: true,
|
|
208
|
+
}]
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The named top-level `const` declaration owns the literal hostname. Derive
|
|
212
|
+
`api` and `www` hostnames from it; use native deployment outputs for actual
|
|
213
|
+
stage-specific origins. Files with a declaration restriction do not gain a
|
|
214
|
+
whole-file exemption. Declaration entries cannot grant ownership to a file
|
|
215
|
+
absent from `allowedFiles`. Existing configurations without these optional
|
|
216
|
+
settings keep their previous behaviour.
|
|
217
|
+
|
|
218
|
+
The assertion exception permits recognised imported Vitest or `@effect/vitest`
|
|
219
|
+
assertions' direct expected arguments, including static arrays and objects.
|
|
220
|
+
It does not trace table-driven expected values through variables or callbacks;
|
|
221
|
+
keep those oracles in narrowly configured declarations or use a local
|
|
222
|
+
suppression explaining their role. It does
|
|
223
|
+
not exempt their actual arguments, setup values, constructors, arbitrary calls
|
|
224
|
+
or shadowed assertion names. A test using unrecognised assertion helpers can
|
|
225
|
+
keep an explicit, narrowly scoped lint suppression with its reason. The rule
|
|
226
|
+
checks written literal text; it cannot prove the value of arbitrary computed
|
|
227
|
+
strings or that a configured canonical owner is semantically correct.
|
|
228
|
+
|
|
229
|
+
## Separate compiler assertions from runtime tests
|
|
230
|
+
|
|
231
|
+
The `effect` group enables `strict-effect/no-runtime-type-assertions`. It rejects
|
|
232
|
+
calls to imported Vitest or `@effect/vitest` `expectTypeOf` and `assertType` in
|
|
233
|
+
`.test.*` and `.spec.*` files. Aliases and namespace imports are recognised;
|
|
234
|
+
unrelated or shadowed local functions are not treated as Vitest APIs.
|
|
235
|
+
|
|
236
|
+
Move these checks into the existing TypeScript flow, for example
|
|
237
|
+
`inference.typecheck.ts`, or a configured Vitest `.test-d.ts` file. Include those
|
|
238
|
+
files in the compiler project and preserve negative brand/readonly assertions.
|
|
239
|
+
Do not create runtime services or registries only to feed a type assertion.
|
|
240
|
+
This syntax rule does not prove that the receiving compiler configuration
|
|
241
|
+
includes the destination file.
|
|
242
|
+
|
|
243
|
+
## Register one owner for a prohibition
|
|
244
|
+
|
|
245
|
+
`package/no-redundant-rule-registrations` is opt-in. Configure pairs only after
|
|
246
|
+
checking that the owner covers the redundant rule under the receiving project's
|
|
247
|
+
options and file scopes:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
"package/no-redundant-rule-registrations": ["error", {
|
|
251
|
+
pairs: [{ owner: "effect/no-throw", redundant: "legacy/no-throw" }],
|
|
252
|
+
}]
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Those identifiers are illustrative consumer rules, not bundled equivalences.
|
|
256
|
+
The check reports when both members are statically enabled in the same Oxlint
|
|
257
|
+
`rules` object. Disabled owners do not establish redundancy. Separate overrides
|
|
258
|
+
are checked independently. An opaque spread or dynamic key clears preceding
|
|
259
|
+
known registrations; later explicit keys can establish them again. Imported or
|
|
260
|
+
spread contents still require effective-configuration review. Keep negative fixtures that
|
|
261
|
+
assert the intended diagnostic once and the redundant diagnostic is absent.
|
|
262
|
+
There is no automatic fix: removing a registration needs coverage evidence.
|
package/dist/alchemy.js
CHANGED
|
@@ -2,97 +2,8 @@ import { defineRule, eslintCompatPlugin } from "@oxlint/plugins";
|
|
|
2
2
|
import * as Array from "effect/Array";
|
|
3
3
|
import * as Option from "effect/Option";
|
|
4
4
|
import * as Predicate from "effect/Predicate";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
definition.parent?.type === "VariableDeclaration" &&
|
|
8
|
-
definition.parent.kind === "const"
|
|
9
|
-
? Option.some(definition.node)
|
|
10
|
-
: Option.none()));
|
|
11
|
-
const propertyName = (node) => {
|
|
12
|
-
if (node.type === "Identifier")
|
|
13
|
-
return Option.some(node.name);
|
|
14
|
-
if (node.type === "Literal" && Predicate.isString(node.value))
|
|
15
|
-
return Option.some(node.value);
|
|
16
|
-
return Option.none();
|
|
17
|
-
};
|
|
18
|
-
const member = (reference, name) => {
|
|
19
|
-
if (Option.isSome(reference.name))
|
|
20
|
-
return Option.none();
|
|
21
|
-
return reference.module === "alchemy" ||
|
|
22
|
-
reference.module === "effect" ||
|
|
23
|
-
(reference.module === "effect/process" && name === "ChildProcess")
|
|
24
|
-
? Option.some({ module: `${reference.module}/${name}`, name: Option.none() })
|
|
25
|
-
: Option.some({ ...reference, name: Option.some(name) });
|
|
26
|
-
};
|
|
27
|
-
// Only real imports and immutable local aliases count. The nearest declaration
|
|
28
|
-
// prevents a shadowed parameter from borrowing an outer import's identity.
|
|
29
|
-
const referenceOf = (context, node, visited = []) => {
|
|
30
|
-
if (Array.some(visited, (previous) => previous === node))
|
|
31
|
-
return Option.none();
|
|
32
|
-
const next = [...visited, node];
|
|
33
|
-
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") {
|
|
34
|
-
return referenceOf(context, node.expression, next);
|
|
35
|
-
}
|
|
36
|
-
if (node.type === "MemberExpression") {
|
|
37
|
-
const name = node.computed
|
|
38
|
-
? node.property.type === "Literal"
|
|
39
|
-
? propertyName(node.property)
|
|
40
|
-
: Option.none()
|
|
41
|
-
: propertyName(node.property);
|
|
42
|
-
return Option.all([referenceOf(context, node.object, next), name]).pipe(Option.flatMap(([reference, key]) => member(reference, key)));
|
|
43
|
-
}
|
|
44
|
-
if (node.type !== "Identifier")
|
|
45
|
-
return Option.none();
|
|
46
|
-
return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.flatMap((variable) => Array.head(variable.defs).pipe(Option.flatMap((definition) => {
|
|
47
|
-
if (definition.type === "ImportBinding" &&
|
|
48
|
-
definition.parent?.type === "ImportDeclaration" &&
|
|
49
|
-
definition.parent.importKind !== "type") {
|
|
50
|
-
const module = definition.parent.source.value;
|
|
51
|
-
if (module !== "alchemy" &&
|
|
52
|
-
!module.startsWith("alchemy/") &&
|
|
53
|
-
module !== "effect" &&
|
|
54
|
-
!module.startsWith("effect/"))
|
|
55
|
-
return Option.none();
|
|
56
|
-
const reference = { module, name: Option.none() };
|
|
57
|
-
if (definition.node.type === "ImportNamespaceSpecifier")
|
|
58
|
-
return Option.some(reference);
|
|
59
|
-
if (definition.node.type === "ImportSpecifier" &&
|
|
60
|
-
definition.node.importKind !== "type") {
|
|
61
|
-
return propertyName(definition.node.imported).pipe(Option.flatMap((name) => member(reference, name)));
|
|
62
|
-
}
|
|
63
|
-
return Option.none();
|
|
64
|
-
}
|
|
65
|
-
return constant(variable).pipe(Option.flatMap((declaration) => Option.fromNullishOr(declaration.init).pipe(Option.flatMap((initialiser) => {
|
|
66
|
-
if (declaration.id.type === "Identifier") {
|
|
67
|
-
return referenceOf(context, initialiser, next);
|
|
68
|
-
}
|
|
69
|
-
if (declaration.id.type !== "ObjectPattern")
|
|
70
|
-
return Option.none();
|
|
71
|
-
return Array.findFirst((property) => property.type === "Property" &&
|
|
72
|
-
property.value.type === "Identifier" &&
|
|
73
|
-
property.value.name === node.name &&
|
|
74
|
-
!property.computed)(declaration.id.properties).pipe(Option.flatMap((property) => property.type === "Property"
|
|
75
|
-
? Option.all([
|
|
76
|
-
referenceOf(context, initialiser, next),
|
|
77
|
-
propertyName(property.key),
|
|
78
|
-
]).pipe(Option.flatMap(([reference, key]) => member(reference, key)))
|
|
79
|
-
: Option.none()));
|
|
80
|
-
}))));
|
|
81
|
-
}))));
|
|
82
|
-
};
|
|
83
|
-
const valueOf = (context, node, visited = []) => {
|
|
84
|
-
if (Array.some(visited, (previous) => previous === node))
|
|
85
|
-
return Option.none();
|
|
86
|
-
const next = [...visited, node];
|
|
87
|
-
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") {
|
|
88
|
-
return valueOf(context, node.expression, next);
|
|
89
|
-
}
|
|
90
|
-
if (node.type !== "Identifier")
|
|
91
|
-
return Option.some(node);
|
|
92
|
-
return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.flatMap(constant), Option.filter((declaration) => declaration.id.type === "Identifier"), Option.flatMap((declaration) => Option.fromNullishOr(declaration.init)), Option.flatMap((initialiser) => valueOf(context, initialiser, next)));
|
|
93
|
-
};
|
|
94
|
-
const operation = (context, node, module, name) => referenceOf(context, node).pipe(Option.exists((reference) => (reference.module === module && Option.contains(reference.name, name)) ||
|
|
95
|
-
(reference.module === `${module}/${name}` && Option.isNone(reference.name))));
|
|
5
|
+
import { operation, valueOf } from "./native-reference.js";
|
|
6
|
+
import { propertyName } from "./syntax.js";
|
|
96
7
|
const providerCall = (context, node, module, name) => valueOf(context, node).pipe(Option.filter((value) => value.type === "CallExpression"), Option.filter((value) => operation(context, value.callee, module, name)));
|
|
97
8
|
const field = (node, name) => node.type === "ObjectExpression" &&
|
|
98
9
|
Array.every((property) => property.type === "Property" && property.kind === "init" && !property.computed)(node.properties)
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const noRedundantRuleRegistrations: import("@oxlint/plugins").Rule;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { Array, Option, Predicate, Record, Schema } from "effect";
|
|
3
|
+
import { childNodes, propertyName, variableInScope } from "./syntax.js";
|
|
4
|
+
const RuleId = Schema.String.check(Schema.isPattern(/^\S+$/u));
|
|
5
|
+
const Options = Schema.Struct({
|
|
6
|
+
pairs: Schema.NonEmptyArray(Schema.Struct({ owner: RuleId, redundant: RuleId })),
|
|
7
|
+
});
|
|
8
|
+
// Resolve only a single-use const. Another read could expose the object to a
|
|
9
|
+
// mutation or an opaque function before the configuration is consumed.
|
|
10
|
+
const constantValue = (variable, reference) => Array.head(variable.defs).pipe(Option.filter(() => variable.defs.length === 1), Option.filter(() => Array.every(variable.references, (entry) => (entry.init && entry.isWrite()) || entry.identifier === reference)), Option.flatMap((definition) => {
|
|
11
|
+
if (definition.node.type === "VariableDeclarator" &&
|
|
12
|
+
definition.node.id.type === "Identifier" &&
|
|
13
|
+
definition.parent?.type === "VariableDeclaration" &&
|
|
14
|
+
definition.parent.kind === "const") {
|
|
15
|
+
return Option.fromNullishOr(definition.node.init);
|
|
16
|
+
}
|
|
17
|
+
return Option.none();
|
|
18
|
+
}));
|
|
19
|
+
const staticValue = (context, node, visited = []) => {
|
|
20
|
+
if (Array.some(visited, (previous) => previous === node))
|
|
21
|
+
return Option.none();
|
|
22
|
+
const next = [...visited, node];
|
|
23
|
+
if (node.type === "TSAsExpression" ||
|
|
24
|
+
node.type === "TSSatisfiesExpression" ||
|
|
25
|
+
node.type === "TSNonNullExpression") {
|
|
26
|
+
return staticValue(context, node.expression, next);
|
|
27
|
+
}
|
|
28
|
+
if (node.type === "Identifier") {
|
|
29
|
+
return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.flatMap((variable) => Array.some(variable.defs, (definition) => definition.type === "ImportBinding")
|
|
30
|
+
? Option.some(node)
|
|
31
|
+
: constantValue(variable, node).pipe(Option.flatMap((value) => staticValue(context, value, next)))));
|
|
32
|
+
}
|
|
33
|
+
return Option.some(node);
|
|
34
|
+
};
|
|
35
|
+
// Spreads and dynamic keys invalidate earlier facts. Later explicit properties
|
|
36
|
+
// regain certainty; spread contents and other configuration scopes stay opaque.
|
|
37
|
+
const staticProperties = (context, node) => staticValue(context, node).pipe(Option.filter((value) => value.type === "ObjectExpression"), Option.flatMap((value) => {
|
|
38
|
+
if (value.type !== "ObjectExpression")
|
|
39
|
+
return Option.none();
|
|
40
|
+
const empty = {};
|
|
41
|
+
return Option.some(Array.reduce(value.properties, empty, (known, entry) => {
|
|
42
|
+
if (entry.type !== "Property")
|
|
43
|
+
return empty;
|
|
44
|
+
const key = entry.computed
|
|
45
|
+
? entry.key.type === "Literal" && Predicate.isString(entry.key.value)
|
|
46
|
+
? Option.some(entry.key.value)
|
|
47
|
+
: Option.none()
|
|
48
|
+
: propertyName(entry.key);
|
|
49
|
+
return Option.match(key, {
|
|
50
|
+
onNone: () => empty,
|
|
51
|
+
onSome: (name) => Record.set(known, name, entry),
|
|
52
|
+
});
|
|
53
|
+
}));
|
|
54
|
+
}));
|
|
55
|
+
const importedConfig = (context, node, namespace) => {
|
|
56
|
+
if (node.type !== "Identifier")
|
|
57
|
+
return false;
|
|
58
|
+
return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.flatMap((variable) => Array.head(variable.defs)), Option.exists((definition) => {
|
|
59
|
+
if (definition.type !== "ImportBinding" ||
|
|
60
|
+
definition.parent?.type !== "ImportDeclaration" ||
|
|
61
|
+
definition.parent.source.value !== "oxlint" ||
|
|
62
|
+
definition.parent.importKind === "type") {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (namespace)
|
|
66
|
+
return definition.node.type === "ImportNamespaceSpecifier";
|
|
67
|
+
return (definition.node.type === "ImportSpecifier" &&
|
|
68
|
+
definition.node.importKind !== "type" &&
|
|
69
|
+
Option.contains(propertyName(definition.node.imported), "defineConfig"));
|
|
70
|
+
}));
|
|
71
|
+
};
|
|
72
|
+
const configFunction = (context, node) => {
|
|
73
|
+
if (importedConfig(context, node, false))
|
|
74
|
+
return true;
|
|
75
|
+
return staticValue(context, node).pipe(Option.exists((value) => {
|
|
76
|
+
if (importedConfig(context, value, false))
|
|
77
|
+
return true;
|
|
78
|
+
if (value.type !== "MemberExpression")
|
|
79
|
+
return false;
|
|
80
|
+
return ((!value.computed || value.property.type === "Literal") &&
|
|
81
|
+
Option.contains(propertyName(value.property), "defineConfig") &&
|
|
82
|
+
importedConfig(context, value.object, true));
|
|
83
|
+
}));
|
|
84
|
+
};
|
|
85
|
+
const descendants = (context, node) => [
|
|
86
|
+
node,
|
|
87
|
+
...Array.flatMap(childNodes(context, node), (child) => descendants(context, child)),
|
|
88
|
+
];
|
|
89
|
+
const enabledSeverity = (node) => node.type === "Literal" &&
|
|
90
|
+
(node.value === "error" ||
|
|
91
|
+
node.value === "deny" ||
|
|
92
|
+
node.value === "warn" ||
|
|
93
|
+
node.value === 1 ||
|
|
94
|
+
node.value === 2);
|
|
95
|
+
const enabled = (context, node) => staticValue(context, node).pipe(Option.exists((value) => {
|
|
96
|
+
if (value.type === "Literal") {
|
|
97
|
+
return enabledSeverity(value);
|
|
98
|
+
}
|
|
99
|
+
if (value.type === "ArrayExpression") {
|
|
100
|
+
return Array.head(value.elements).pipe(Option.flatMap(Option.fromNullishOr), Option.flatMap((first) => staticValue(context, first)), Option.exists(enabledSeverity));
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}));
|
|
104
|
+
export const noRedundantRuleRegistrations = defineRule({
|
|
105
|
+
meta: {
|
|
106
|
+
type: "problem",
|
|
107
|
+
docs: {
|
|
108
|
+
description: "Reject explicitly configured redundant lint registrations when their owner is also enabled.",
|
|
109
|
+
},
|
|
110
|
+
schema: [
|
|
111
|
+
{
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: {
|
|
114
|
+
pairs: {
|
|
115
|
+
type: "array",
|
|
116
|
+
minItems: 1,
|
|
117
|
+
items: {
|
|
118
|
+
type: "object",
|
|
119
|
+
properties: {
|
|
120
|
+
owner: { type: "string", minLength: 1 },
|
|
121
|
+
redundant: { type: "string", minLength: 1 },
|
|
122
|
+
},
|
|
123
|
+
required: ["owner", "redundant"],
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
required: ["pairs"],
|
|
129
|
+
additionalProperties: false,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
messages: {
|
|
133
|
+
options: "Configure exact owner/redundant rule pairs whose overlap this consumer has verified.",
|
|
134
|
+
redundant: "{{redundant}} is redundant while {{owner}} is enabled in this rules object. Keep the verified owner.",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
create(context) {
|
|
138
|
+
const options = Array.head(context.options).pipe(Option.flatMap(Schema.decodeUnknownOption(Options)));
|
|
139
|
+
const canonicalFile = /(?:^|\/)oxlint\.config\.[cm]?[jt]s$/u.test(context.filename.replaceAll("\\", "/"));
|
|
140
|
+
const inspect = (node) => {
|
|
141
|
+
Option.match(Option.all([options, staticProperties(context, node)]), {
|
|
142
|
+
onNone: () => { },
|
|
143
|
+
onSome: ([configuration, properties]) => {
|
|
144
|
+
Array.forEach(Array.dedupeWith(configuration.pairs, (left, right) => left.owner === right.owner && left.redundant === right.redundant), ({ owner, redundant }) => {
|
|
145
|
+
if (owner === redundant)
|
|
146
|
+
return;
|
|
147
|
+
const pair = Option.all([
|
|
148
|
+
Record.get(properties, owner),
|
|
149
|
+
Record.get(properties, redundant),
|
|
150
|
+
]);
|
|
151
|
+
Option.match(pair, {
|
|
152
|
+
onNone: () => { },
|
|
153
|
+
onSome: ([ownerProperty, redundantProperty]) => {
|
|
154
|
+
if (enabled(context, ownerProperty.value) &&
|
|
155
|
+
enabled(context, redundantProperty.value)) {
|
|
156
|
+
context.report({
|
|
157
|
+
node: redundantProperty,
|
|
158
|
+
messageId: "redundant",
|
|
159
|
+
data: { owner, redundant },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
const inspectConfiguration = (node) => {
|
|
169
|
+
Option.match(staticProperties(context, node), {
|
|
170
|
+
onNone: () => { },
|
|
171
|
+
onSome: (properties) => {
|
|
172
|
+
Option.match(Record.get(properties, "rules"), {
|
|
173
|
+
onNone: () => { },
|
|
174
|
+
onSome: (rules) => inspect(rules.value),
|
|
175
|
+
});
|
|
176
|
+
Record.get(properties, "overrides").pipe(Option.flatMap((overrides) => staticValue(context, overrides.value)), Option.filter((value) => value.type === "ArrayExpression"), Option.map((value) => {
|
|
177
|
+
if (value.type === "ArrayExpression")
|
|
178
|
+
Array.forEach(value.elements, (entry) => {
|
|
179
|
+
if (entry)
|
|
180
|
+
inspectConfiguration(entry);
|
|
181
|
+
});
|
|
182
|
+
}));
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
return {
|
|
187
|
+
"Program:exit"(program) {
|
|
188
|
+
if (Option.isNone(options)) {
|
|
189
|
+
context.report({ node: program, messageId: "options" });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const roots = Array.flatMap(descendants(context, program), (node) => {
|
|
193
|
+
if (node.type === "CallExpression" && configFunction(context, node.callee)) {
|
|
194
|
+
return Option.toArray(Array.head(node.arguments));
|
|
195
|
+
}
|
|
196
|
+
if (canonicalFile && node.type === "ExportDefaultDeclaration")
|
|
197
|
+
return [node.declaration];
|
|
198
|
+
return [];
|
|
199
|
+
});
|
|
200
|
+
Array.forEach(Array.dedupeWith(roots, (left, right) => left === right), inspectConfiguration);
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export declare const effect: {
|
|
|
31
31
|
"effect/no-manual-tag": "error";
|
|
32
32
|
"effect/no-raw-fetch": "error";
|
|
33
33
|
"strict-effect/static-layers": "error";
|
|
34
|
+
"strict-effect/subprocess-pipe-capture": "error";
|
|
35
|
+
"strict-effect/no-runtime-type-assertions": "error";
|
|
34
36
|
"strict-effect/no-unchecked-index": "error";
|
|
35
37
|
"strict-effect/no-native-at": "error";
|
|
36
38
|
"strict-effect/tagged-error-name": "error";
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,8 @@ export const effect = {
|
|
|
36
36
|
"effect/no-manual-tag": "error",
|
|
37
37
|
"effect/no-raw-fetch": "error",
|
|
38
38
|
"strict-effect/static-layers": "error",
|
|
39
|
+
"strict-effect/subprocess-pipe-capture": "error",
|
|
40
|
+
"strict-effect/no-runtime-type-assertions": "error",
|
|
39
41
|
"strict-effect/no-unchecked-index": "error",
|
|
40
42
|
"strict-effect/no-native-at": "error",
|
|
41
43
|
"strict-effect/tagged-error-name": "error",
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Context, ESTree, Variable } from "@oxlint/plugins";
|
|
2
|
+
import * as Option from "effect/Option";
|
|
3
|
+
export declare const constant: (variable: Variable) => Option.Option<ESTree.VariableDeclarator>;
|
|
4
|
+
export declare const valueOf: (context: Context, node: ESTree.Node, visited?: readonly ESTree.Node[]) => Option.Option<ESTree.Node>;
|
|
5
|
+
export declare const operation: (context: Context, node: ESTree.Node, module: string, name: string) => boolean;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as Array from "effect/Array";
|
|
2
|
+
import * as Option from "effect/Option";
|
|
3
|
+
import { propertyName, variableInScope } from "./syntax.js";
|
|
4
|
+
export const constant = (variable) => Array.head(variable.defs).pipe(Option.filter(() => variable.defs.length === 1), Option.filter(() => Array.every((reference) => !reference.isWrite() || reference.init)(variable.references)), Option.flatMap((definition) => definition.node.type === "VariableDeclarator" &&
|
|
5
|
+
definition.parent?.type === "VariableDeclaration" &&
|
|
6
|
+
definition.parent.kind === "const"
|
|
7
|
+
? Option.some(definition.node)
|
|
8
|
+
: Option.none()));
|
|
9
|
+
const member = (reference, name) => {
|
|
10
|
+
if (Option.isSome(reference.name))
|
|
11
|
+
return Option.none();
|
|
12
|
+
return reference.module === "alchemy" ||
|
|
13
|
+
reference.module === "effect" ||
|
|
14
|
+
(reference.module === "effect/process" &&
|
|
15
|
+
(name === "ChildProcess" || name === "ChildProcessSpawner"))
|
|
16
|
+
? Option.some({ module: `${reference.module}/${name}`, name: Option.none() })
|
|
17
|
+
: Option.some({ ...reference, name: Option.some(name) });
|
|
18
|
+
};
|
|
19
|
+
// Only real imports and immutable local aliases count. The nearest declaration
|
|
20
|
+
// prevents a shadowed parameter from borrowing an outer import's identity.
|
|
21
|
+
const referenceOf = (context, node, visited = []) => {
|
|
22
|
+
if (Array.some(visited, (previous) => previous === node))
|
|
23
|
+
return Option.none();
|
|
24
|
+
const next = [...visited, node];
|
|
25
|
+
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") {
|
|
26
|
+
return referenceOf(context, node.expression, next);
|
|
27
|
+
}
|
|
28
|
+
if (node.type === "MemberExpression") {
|
|
29
|
+
const name = node.computed
|
|
30
|
+
? node.property.type === "Literal"
|
|
31
|
+
? propertyName(node.property)
|
|
32
|
+
: Option.none()
|
|
33
|
+
: propertyName(node.property);
|
|
34
|
+
return Option.all([referenceOf(context, node.object, next), name]).pipe(Option.flatMap(([reference, key]) => member(reference, key)));
|
|
35
|
+
}
|
|
36
|
+
if (node.type !== "Identifier")
|
|
37
|
+
return Option.none();
|
|
38
|
+
return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.flatMap((variable) => Array.head(variable.defs).pipe(Option.flatMap((definition) => {
|
|
39
|
+
if (definition.type === "ImportBinding" &&
|
|
40
|
+
definition.parent?.type === "ImportDeclaration" &&
|
|
41
|
+
definition.parent.importKind !== "type") {
|
|
42
|
+
const module = definition.parent.source.value;
|
|
43
|
+
if (module !== "alchemy" &&
|
|
44
|
+
!module.startsWith("alchemy/") &&
|
|
45
|
+
module !== "effect" &&
|
|
46
|
+
!module.startsWith("effect/"))
|
|
47
|
+
return Option.none();
|
|
48
|
+
const reference = { module, name: Option.none() };
|
|
49
|
+
if (definition.node.type === "ImportNamespaceSpecifier")
|
|
50
|
+
return Option.some(reference);
|
|
51
|
+
if (definition.node.type === "ImportSpecifier" &&
|
|
52
|
+
definition.node.importKind !== "type") {
|
|
53
|
+
return propertyName(definition.node.imported).pipe(Option.flatMap((name) => member(reference, name)));
|
|
54
|
+
}
|
|
55
|
+
return Option.none();
|
|
56
|
+
}
|
|
57
|
+
return constant(variable).pipe(Option.flatMap((declaration) => Option.fromNullishOr(declaration.init).pipe(Option.flatMap((initialiser) => {
|
|
58
|
+
if (declaration.id.type === "Identifier") {
|
|
59
|
+
return referenceOf(context, initialiser, next);
|
|
60
|
+
}
|
|
61
|
+
if (declaration.id.type !== "ObjectPattern")
|
|
62
|
+
return Option.none();
|
|
63
|
+
return Array.findFirst((property) => property.type === "Property" &&
|
|
64
|
+
property.value.type === "Identifier" &&
|
|
65
|
+
property.value.name === node.name &&
|
|
66
|
+
!property.computed)(declaration.id.properties).pipe(Option.flatMap((property) => property.type === "Property"
|
|
67
|
+
? Option.all([
|
|
68
|
+
referenceOf(context, initialiser, next),
|
|
69
|
+
propertyName(property.key),
|
|
70
|
+
]).pipe(Option.flatMap(([reference, key]) => member(reference, key)))
|
|
71
|
+
: Option.none()));
|
|
72
|
+
}))));
|
|
73
|
+
}))));
|
|
74
|
+
};
|
|
75
|
+
export const valueOf = (context, node, visited = []) => {
|
|
76
|
+
if (Array.some(visited, (previous) => previous === node))
|
|
77
|
+
return Option.none();
|
|
78
|
+
const next = [...visited, node];
|
|
79
|
+
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") {
|
|
80
|
+
return valueOf(context, node.expression, next);
|
|
81
|
+
}
|
|
82
|
+
if (node.type !== "Identifier")
|
|
83
|
+
return Option.some(node);
|
|
84
|
+
return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.flatMap(constant), Option.filter((declaration) => declaration.id.type === "Identifier"), Option.flatMap((declaration) => Option.fromNullishOr(declaration.init)), Option.flatMap((initialiser) => valueOf(context, initialiser, next)));
|
|
85
|
+
};
|
|
86
|
+
export const operation = (context, node, module, name) => referenceOf(context, node).pipe(Option.exists((reference) => (reference.module === module && Option.contains(reference.name, name)) ||
|
|
87
|
+
(reference.module === `${module}/${name}` && Option.isNone(reference.name))));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const workspaceImportBoundariesRule: import("@oxlint/plugins").Rule;
|