@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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import { Array, Number, Option, Predicate, Record, Schema } from "effect";
|
|
3
|
+
import { variableInScope } from "../syntax.js";
|
|
4
|
+
// A checked manifest consumer selects export conditions before configuring this
|
|
5
|
+
// pure rule. A false entry preserves a manifest's explicit null export fence.
|
|
6
|
+
const Options = Schema.Struct({
|
|
7
|
+
owners: Schema.Array(Schema.Struct({ id: Schema.String, root: Schema.String })),
|
|
8
|
+
workspaces: Schema.Array(Schema.Struct({
|
|
9
|
+
name: Schema.String,
|
|
10
|
+
owner: Schema.String,
|
|
11
|
+
exports: Schema.Record(Schema.String, Schema.Boolean),
|
|
12
|
+
})),
|
|
13
|
+
workspacePrefixes: Schema.Array(Schema.String),
|
|
14
|
+
forbiddenEdges: Schema.Array(Schema.Struct({ from: Schema.String, to: Schema.String })),
|
|
15
|
+
exceptions: Schema.Array(Schema.Struct({ fromFile: Schema.String, toModule: Schema.String })),
|
|
16
|
+
aliases: Schema.Array(Schema.Struct({ prefix: Schema.String, root: Schema.String })),
|
|
17
|
+
});
|
|
18
|
+
const normalise = (path) => path.replaceAll("\\", "/");
|
|
19
|
+
const inside = (path, root) => root === "." || path === root || path.startsWith(`${root}/`);
|
|
20
|
+
const cleanPath = (path) => path.length > 0 &&
|
|
21
|
+
!/[\\:*?#%]/u.test(path) &&
|
|
22
|
+
!path.includes("\u0000") &&
|
|
23
|
+
!path.startsWith("/") &&
|
|
24
|
+
Array.every(path.split("/"), (part) => part.length > 0 && part !== "." && part !== "..");
|
|
25
|
+
const rootPath = (path) => path === "." || cleanPath(path);
|
|
26
|
+
const packageName = (name) => /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u.test(name);
|
|
27
|
+
const unique = (values) => Array.dedupe(values).length === values.length;
|
|
28
|
+
const ownerAt = (options, path) => Array.head(Array.sort(Array.filter(options.owners, ({ root }) => inside(path, root)), (left, right) => Number.Order(right.root === "." ? 0 : right.root.length, left.root === "." ? 0 : left.root.length)));
|
|
29
|
+
const exportKey = (key) => key === "." ||
|
|
30
|
+
(key.startsWith("./") &&
|
|
31
|
+
key.split("*").length <= 2 &&
|
|
32
|
+
cleanPath(key.slice(2).replace("*", "pattern")));
|
|
33
|
+
const validSubpath = (key) => key === "." ||
|
|
34
|
+
(key.startsWith("./") &&
|
|
35
|
+
cleanPath(key.slice(2)) &&
|
|
36
|
+
!Array.contains(key.slice(2).split("/"), "node_modules"));
|
|
37
|
+
const patternMatch = (pattern, key) => {
|
|
38
|
+
const star = pattern.indexOf("*");
|
|
39
|
+
return (star >= 0 &&
|
|
40
|
+
key.startsWith(pattern.slice(0, star)) &&
|
|
41
|
+
key.endsWith(pattern.slice(star + 1)) &&
|
|
42
|
+
key.length >= pattern.length - 1);
|
|
43
|
+
};
|
|
44
|
+
const exported = (exports, key) => {
|
|
45
|
+
if (!validSubpath(key))
|
|
46
|
+
return false;
|
|
47
|
+
return Record.get(exports, key).pipe(Option.orElse(() => Array.head(Array.sort(Array.filter(Record.keys(exports), (pattern) => patternMatch(pattern, key)), (left, right) => Number.Order(right.indexOf("*"), left.indexOf("*")) ||
|
|
48
|
+
Number.Order(right.length, left.length))).pipe(Option.flatMap((pattern) => Record.get(exports, pattern)))), Option.getOrElse(() => false));
|
|
49
|
+
};
|
|
50
|
+
const workspaceAt = (options, address) => Array.findFirst(options.workspaces, ({ name }) => address === name || address.startsWith(`${name}/`));
|
|
51
|
+
const publicAddress = (options, address) => Option.exists(workspaceAt(options, address), (workspace) => exported(workspace.exports, address === workspace.name ? "." : `.${address.slice(workspace.name.length)}`));
|
|
52
|
+
const validOptions = (options) => Array.some(options.owners, ({ root }) => root === ".") &&
|
|
53
|
+
unique(Array.map(options.owners, ({ id }) => id)) &&
|
|
54
|
+
unique(Array.map(options.owners, ({ root }) => root)) &&
|
|
55
|
+
Array.every(options.owners, ({ id, root }) => id.length > 0 && rootPath(root)) &&
|
|
56
|
+
unique(Array.map(options.workspaces, ({ name }) => name)) &&
|
|
57
|
+
unique(Array.map(options.workspaces, ({ owner }) => owner)) &&
|
|
58
|
+
Array.every(options.workspaces, (workspace) => packageName(workspace.name) &&
|
|
59
|
+
Array.some(options.owners, ({ id }) => id === workspace.owner) &&
|
|
60
|
+
Array.every(Record.keys(workspace.exports), exportKey)) &&
|
|
61
|
+
unique(options.workspacePrefixes) &&
|
|
62
|
+
Array.every(options.workspacePrefixes, (prefix) => /^@[a-z0-9][a-z0-9._-]*\/$/u.test(prefix) || packageName(prefix)) &&
|
|
63
|
+
unique(Array.map(options.forbiddenEdges, ({ from, to }) => `${from}\u0000${to}`)) &&
|
|
64
|
+
Array.every(options.forbiddenEdges, ({ from, to }) => from !== to &&
|
|
65
|
+
Array.every([from, to], (endpoint) => Array.some(options.owners, ({ id }) => id === endpoint))) &&
|
|
66
|
+
unique(Array.map(options.aliases, ({ prefix }) => prefix)) &&
|
|
67
|
+
Array.every(options.aliases, ({ prefix, root }) => prefix.endsWith("/") &&
|
|
68
|
+
(prefix === "#/" || cleanPath(prefix.slice(0, -1))) &&
|
|
69
|
+
rootPath(root) &&
|
|
70
|
+
!Array.some(options.workspaces, ({ name }) => name.startsWith(prefix) || prefix.startsWith(`${name}/`))) &&
|
|
71
|
+
unique(Array.map(options.exceptions, ({ fromFile, toModule }) => `${fromFile}\u0000${toModule}`)) &&
|
|
72
|
+
Array.every(options.exceptions, ({ fromFile, toModule }) => cleanPath(fromFile) &&
|
|
73
|
+
publicAddress(options, toModule) &&
|
|
74
|
+
Option.exists(Option.all([ownerAt(options, fromFile), workspaceAt(options, toModule)]), ([from, to]) => Array.some(options.forbiddenEdges, (edge) => edge.from === from.id && edge.to === to.owner)));
|
|
75
|
+
// Scope-aware immutable string aliases only; no evaluation or type resolution.
|
|
76
|
+
const moduleAddress = (context, node, visited = []) => {
|
|
77
|
+
if (visited.length >= 64 || Array.some(visited, (previous) => previous === node))
|
|
78
|
+
return Option.none();
|
|
79
|
+
const next = [...visited, node];
|
|
80
|
+
if (node.type === "Literal" && Predicate.isString(node.value))
|
|
81
|
+
return Option.some(node.value);
|
|
82
|
+
if (node.type === "TemplateLiteral") {
|
|
83
|
+
return Option.all(Array.map(node.quasis, (quasi, index) => Option.all([
|
|
84
|
+
Option.fromNullishOr(quasi.value.cooked),
|
|
85
|
+
Array.get(node.expressions, index).pipe(Option.match({
|
|
86
|
+
onNone: () => Option.some(""),
|
|
87
|
+
onSome: (expression) => moduleAddress(context, expression, next),
|
|
88
|
+
})),
|
|
89
|
+
]).pipe(Option.map(([text, expression]) => text + expression)))).pipe(Option.map(Array.join("")));
|
|
90
|
+
}
|
|
91
|
+
if (node.type === "BinaryExpression" && node.operator === "+") {
|
|
92
|
+
return Option.all([
|
|
93
|
+
moduleAddress(context, node.left, next),
|
|
94
|
+
moduleAddress(context, node.right, next),
|
|
95
|
+
]).pipe(Option.map(([left, right]) => left + right));
|
|
96
|
+
}
|
|
97
|
+
if (node.type !== "Identifier")
|
|
98
|
+
return Option.none();
|
|
99
|
+
return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.filter((variable) => variable.defs.length === 1 &&
|
|
100
|
+
Array.every(variable.references, (reference) => !reference.isWrite() || reference.init)), Option.flatMap((variable) => Array.head(variable.defs)), Option.flatMap((definition) => definition.node.type === "VariableDeclarator" &&
|
|
101
|
+
definition.node.id.type === "Identifier" &&
|
|
102
|
+
definition.parent?.type === "VariableDeclaration" &&
|
|
103
|
+
definition.parent.kind === "const"
|
|
104
|
+
? Option.fromNullishOr(definition.node.init)
|
|
105
|
+
: Option.none()), Option.flatMap((initialiser) => moduleAddress(context, initialiser, next)));
|
|
106
|
+
};
|
|
107
|
+
const resolvePath = (base, address) => Array.join(Array.reduce(address.split("/"), base === "." || base === "" ? [] : base.split("/"), (parts, part) => part === ".."
|
|
108
|
+
? parts.length === 0 || Option.contains(Array.last(parts), "..")
|
|
109
|
+
? [...parts, ".."]
|
|
110
|
+
: Array.dropRight(parts, 1)
|
|
111
|
+
: part === "." || part === ""
|
|
112
|
+
? parts
|
|
113
|
+
: [...parts, part]), "/");
|
|
114
|
+
const relativeFile = (filename, cwd) => {
|
|
115
|
+
const file = normalise(filename);
|
|
116
|
+
const root = normalise(cwd).replace(/\/$/u, "");
|
|
117
|
+
return file.startsWith(`${root}/`) ? Option.some(file.slice(root.length + 1)) : Option.none();
|
|
118
|
+
};
|
|
119
|
+
const fileAddress = (address) => address.replace(/[?#].*$/u, "");
|
|
120
|
+
const forbidden = (options, file, rawAddress, cwd) => {
|
|
121
|
+
const address = normalise(rawAddress);
|
|
122
|
+
const from = ownerAt(options, file);
|
|
123
|
+
const alias = Array.head(Array.sort(Array.filter(options.aliases, ({ prefix }) => address.startsWith(prefix)), (left, right) => Number.Order(right.prefix.length, left.prefix.length)));
|
|
124
|
+
if (Option.isSome(alias)) {
|
|
125
|
+
const target = resolvePath(alias.value.root, fileAddress(address.slice(alias.value.prefix.length)));
|
|
126
|
+
return (!cleanPath(target) ||
|
|
127
|
+
!inside(target, alias.value.root) ||
|
|
128
|
+
!Option.exists(Option.all([from, ownerAt(options, target)]), ([a, b]) => a.id === b.id));
|
|
129
|
+
}
|
|
130
|
+
if (address.startsWith(".") ||
|
|
131
|
+
address.startsWith("/") ||
|
|
132
|
+
/^[a-z]:\//iu.test(address) ||
|
|
133
|
+
address.startsWith("file:")) {
|
|
134
|
+
const target = address.startsWith(".")
|
|
135
|
+
? Option.some(resolvePath(Array.join(Array.dropRight(file.split("/"), 1), "/"), fileAddress(address)))
|
|
136
|
+
: relativeFile(fileAddress(address), cwd).pipe(Option.map((path) => resolvePath(".", path)));
|
|
137
|
+
return !Option.exists(target, (path) => cleanPath(path) &&
|
|
138
|
+
Option.exists(Option.all([from, ownerAt(options, path)]), ([a, b]) => a.id === b.id));
|
|
139
|
+
}
|
|
140
|
+
return Option.match(workspaceAt(options, address), {
|
|
141
|
+
onNone: () => Array.some(options.workspacePrefixes, (prefix) => prefix.endsWith("/")
|
|
142
|
+
? address.startsWith(prefix)
|
|
143
|
+
: address === prefix || address.startsWith(`${prefix}/`)),
|
|
144
|
+
onSome: (workspace) => !publicAddress(options, address) ||
|
|
145
|
+
(Option.exists(from, (owner) => Array.some(options.forbiddenEdges, (edge) => edge.from === owner.id && edge.to === workspace.owner)) &&
|
|
146
|
+
!Array.some(options.exceptions, (exception) => exception.fromFile === file && exception.toModule === address)),
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
export const workspaceImportBoundariesRule = defineRule({
|
|
150
|
+
meta: {
|
|
151
|
+
type: "problem",
|
|
152
|
+
docs: {
|
|
153
|
+
description: "Enforce checked workspace exports, owner edges and exact public-import exceptions.",
|
|
154
|
+
},
|
|
155
|
+
schema: [
|
|
156
|
+
{
|
|
157
|
+
type: "object",
|
|
158
|
+
additionalProperties: false,
|
|
159
|
+
required: [
|
|
160
|
+
"owners",
|
|
161
|
+
"workspaces",
|
|
162
|
+
"workspacePrefixes",
|
|
163
|
+
"forbiddenEdges",
|
|
164
|
+
"exceptions",
|
|
165
|
+
"aliases",
|
|
166
|
+
],
|
|
167
|
+
properties: {
|
|
168
|
+
owners: {
|
|
169
|
+
type: "array",
|
|
170
|
+
items: {
|
|
171
|
+
type: "object",
|
|
172
|
+
additionalProperties: false,
|
|
173
|
+
required: ["id", "root"],
|
|
174
|
+
properties: { id: { type: "string" }, root: { type: "string" } },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
workspaces: {
|
|
178
|
+
type: "array",
|
|
179
|
+
items: {
|
|
180
|
+
type: "object",
|
|
181
|
+
additionalProperties: false,
|
|
182
|
+
required: ["name", "owner", "exports"],
|
|
183
|
+
properties: {
|
|
184
|
+
name: { type: "string" },
|
|
185
|
+
owner: { type: "string" },
|
|
186
|
+
exports: { type: "object", additionalProperties: { type: "boolean" } },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
workspacePrefixes: { type: "array", items: { type: "string" } },
|
|
191
|
+
forbiddenEdges: {
|
|
192
|
+
type: "array",
|
|
193
|
+
items: {
|
|
194
|
+
type: "object",
|
|
195
|
+
additionalProperties: false,
|
|
196
|
+
required: ["from", "to"],
|
|
197
|
+
properties: { from: { type: "string" }, to: { type: "string" } },
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
exceptions: {
|
|
201
|
+
type: "array",
|
|
202
|
+
items: {
|
|
203
|
+
type: "object",
|
|
204
|
+
additionalProperties: false,
|
|
205
|
+
required: ["fromFile", "toModule"],
|
|
206
|
+
properties: { fromFile: { type: "string" }, toModule: { type: "string" } },
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
aliases: {
|
|
210
|
+
type: "array",
|
|
211
|
+
items: {
|
|
212
|
+
type: "object",
|
|
213
|
+
additionalProperties: false,
|
|
214
|
+
required: ["prefix", "root"],
|
|
215
|
+
properties: { prefix: { type: "string" }, root: { type: "string" } },
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
messages: {
|
|
222
|
+
options: "Supply valid checked workspace contracts, anchored owners and exact exceptions; boundary configuration is missing or inconsistent.",
|
|
223
|
+
boundary: "Use an allowed public workspace export. Direct owner crossings, private exports and forbidden owner edges are denied.",
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
create(context) {
|
|
227
|
+
const options = Array.head(context.options).pipe(Option.flatMap(Schema.decodeUnknownOption(Options, { onExcessProperty: "error" })), Option.filter(validOptions));
|
|
228
|
+
const file = relativeFile(context.filename, context.cwd);
|
|
229
|
+
const inspect = (node, source) => {
|
|
230
|
+
if (Option.isNone(options) || Option.isNone(file))
|
|
231
|
+
return;
|
|
232
|
+
Option.match(moduleAddress(context, source), {
|
|
233
|
+
onNone: () => undefined,
|
|
234
|
+
onSome: (address) => {
|
|
235
|
+
if (forbidden(options.value, file.value, address, context.cwd))
|
|
236
|
+
context.report({ node, messageId: "boundary" });
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
Program(node) {
|
|
242
|
+
if (context.options.length !== 1 || Option.isNone(options) || Option.isNone(file))
|
|
243
|
+
context.report({ node, messageId: "options" });
|
|
244
|
+
},
|
|
245
|
+
ImportDeclaration(node) {
|
|
246
|
+
inspect(node, node.source);
|
|
247
|
+
},
|
|
248
|
+
ImportExpression(node) {
|
|
249
|
+
inspect(node, node.source);
|
|
250
|
+
},
|
|
251
|
+
ExportNamedDeclaration(node) {
|
|
252
|
+
if (node.source)
|
|
253
|
+
inspect(node, node.source);
|
|
254
|
+
},
|
|
255
|
+
ExportAllDeclaration(node) {
|
|
256
|
+
inspect(node, node.source);
|
|
257
|
+
},
|
|
258
|
+
TSImportType(node) {
|
|
259
|
+
inspect(node, node.source);
|
|
260
|
+
},
|
|
261
|
+
TSImportEqualsDeclaration(node) {
|
|
262
|
+
if (node.moduleReference.type === "TSExternalModuleReference")
|
|
263
|
+
inspect(node, node.moduleReference.expression);
|
|
264
|
+
},
|
|
265
|
+
CallExpression(node) {
|
|
266
|
+
if (node.callee.type === "Identifier" &&
|
|
267
|
+
node.callee.name === "require" &&
|
|
268
|
+
Option.isNone(variableInScope(context.sourceCode.getScope(node), "require"))) {
|
|
269
|
+
Option.match(Array.head(node.arguments), {
|
|
270
|
+
onNone: () => undefined,
|
|
271
|
+
onSome: (source) => inspect(node, source),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
});
|
package/dist/package.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ declare const _default: {
|
|
|
4
4
|
};
|
|
5
5
|
rules: {
|
|
6
6
|
"no-cross-package-source-imports": import("@oxlint/plugins").Rule;
|
|
7
|
+
"workspace-import-boundaries": import("@oxlint/plugins").Rule;
|
|
8
|
+
"no-redundant-rule-registrations": import("@oxlint/plugins").Rule;
|
|
7
9
|
};
|
|
8
10
|
};
|
|
9
11
|
export default _default;
|
package/dist/package.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { defineRule } from "@oxlint/plugins";
|
|
2
2
|
import { Array, Option, Predicate } from "effect";
|
|
3
|
+
import { workspaceImportBoundariesRule } from "./package/workspace-import-boundaries.js";
|
|
4
|
+
import { noRedundantRuleRegistrations } from "./config.js";
|
|
3
5
|
const resolveRelative = (fileName, specifier) => Array.join(Array.reduce(specifier.split("/"), Array.dropRight(fileName.split("/"), 1), (segments, segment) => segment === ".."
|
|
4
6
|
? Array.dropRight(segments, 1)
|
|
5
7
|
: segment === "." || segment.length === 0
|
|
@@ -46,5 +48,7 @@ export default {
|
|
|
46
48
|
meta: { name: "package" },
|
|
47
49
|
rules: {
|
|
48
50
|
"no-cross-package-source-imports": noCrossPackageSourceImports,
|
|
51
|
+
"workspace-import-boundaries": workspaceImportBoundariesRule,
|
|
52
|
+
"no-redundant-rule-registrations": noRedundantRuleRegistrations,
|
|
49
53
|
},
|
|
50
54
|
};
|
package/dist/strict-effect.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { subprocessPipeCapture } from "./subprocess-pipe-capture.js";
|
|
1
2
|
import { defineRule, eslintCompatPlugin } from "@oxlint/plugins";
|
|
2
3
|
import * as Array from "effect/Array";
|
|
3
4
|
import * as Function from "effect/Function";
|
|
@@ -720,6 +721,7 @@ const scopedImportedReference = (context, expression, visited = [], acceptsModul
|
|
|
720
721
|
}
|
|
721
722
|
if (declaration.id.type === "ObjectPattern") {
|
|
722
723
|
return Array.findFirst((property) => property.type === "Property" &&
|
|
724
|
+
(!property.computed || property.key.type === "Literal") &&
|
|
723
725
|
property.value.type === "Identifier" &&
|
|
724
726
|
property.value.name === name)(declaration.id.properties).pipe(Option.flatMap((property) => {
|
|
725
727
|
if (property.type === "Property") {
|
|
@@ -1797,6 +1799,61 @@ const websiteDataProxy = defineRule({
|
|
|
1797
1799
|
// Structure ownership: facts with one owner may be written only in that owner.
|
|
1798
1800
|
// Owner paths are exact and relative to Oxlint's working directory.
|
|
1799
1801
|
const testFile = (filename) => /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(filename);
|
|
1802
|
+
const vitestModule = (source) => source === "vitest" || source === "@effect/vitest";
|
|
1803
|
+
// Keep lexical import provenance through immutable aliases, including static
|
|
1804
|
+
// assertion members. Unknown, shadowed and reassigned bindings are not trusted.
|
|
1805
|
+
const testReference = (context, node, visited = []) => {
|
|
1806
|
+
if (Array.some(visited, (previous) => previous === node))
|
|
1807
|
+
return Option.none();
|
|
1808
|
+
const next = [...visited, node];
|
|
1809
|
+
const imported = scopedImportedReference(context, node, [], vitestModule);
|
|
1810
|
+
if (Option.isSome(imported)) {
|
|
1811
|
+
return Option.some({ path: Option.toArray(imported.value.name) });
|
|
1812
|
+
}
|
|
1813
|
+
if (node.type === "MemberExpression" && (!node.computed || node.property.type === "Literal")) {
|
|
1814
|
+
return Option.all([
|
|
1815
|
+
testReference(context, node.object, next),
|
|
1816
|
+
propertyName(node.property),
|
|
1817
|
+
]).pipe(Option.map(([reference, name]) => ({ path: [...reference.path, name] })));
|
|
1818
|
+
}
|
|
1819
|
+
if (node.type !== "Identifier")
|
|
1820
|
+
return Option.none();
|
|
1821
|
+
return scopedVariable(context.sourceCode.getScope(node), node.name).pipe(Option.flatMap(constantDeclaration), Option.flatMap((declaration) => Option.fromNullishOr(declaration.init).pipe(Option.flatMap((initialiser) => testReference(context, initialiser, next)), Option.flatMap((reference) => {
|
|
1822
|
+
if (declaration.id.type === "Identifier")
|
|
1823
|
+
return Option.some(reference);
|
|
1824
|
+
if (declaration.id.type !== "ObjectPattern")
|
|
1825
|
+
return Option.none();
|
|
1826
|
+
return Array.findFirst(declaration.id.properties, (property) => property.type === "Property" &&
|
|
1827
|
+
(!property.computed || property.key.type === "Literal") &&
|
|
1828
|
+
property.value.type === "Identifier" &&
|
|
1829
|
+
property.value.name === node.name).pipe(Option.flatMap((property) => {
|
|
1830
|
+
if (property.type === "Property")
|
|
1831
|
+
return propertyName(property.key);
|
|
1832
|
+
return Option.none();
|
|
1833
|
+
}), Option.map((name) => ({ path: [...reference.path, name] })));
|
|
1834
|
+
}))));
|
|
1835
|
+
};
|
|
1836
|
+
const noRuntimeTypeAssertions = defineRule({
|
|
1837
|
+
meta: {
|
|
1838
|
+
type: "problem",
|
|
1839
|
+
schema: [],
|
|
1840
|
+
messages: {
|
|
1841
|
+
typeAssertion: "Move compile-only type assertions to a .typecheck.ts or .test-d.ts file checked by TypeScript; runtime tests must exercise behaviour.",
|
|
1842
|
+
},
|
|
1843
|
+
},
|
|
1844
|
+
create(context) {
|
|
1845
|
+
if (!testFile(context.filename))
|
|
1846
|
+
return {};
|
|
1847
|
+
return {
|
|
1848
|
+
CallExpression(node) {
|
|
1849
|
+
if (Option.exists(testReference(context, node.callee), ({ path }) => path.length === 1 &&
|
|
1850
|
+
Array.some(path, (name) => name === "expectTypeOf" || name === "assertType"))) {
|
|
1851
|
+
context.report({ node, messageId: "typeAssertion" });
|
|
1852
|
+
}
|
|
1853
|
+
},
|
|
1854
|
+
};
|
|
1855
|
+
},
|
|
1856
|
+
});
|
|
1800
1857
|
const ownedBy = (context, files) => Array.some(files, (file) => exactOwnedFile(context.filename, context.cwd, file));
|
|
1801
1858
|
const ownerFilesSchema = { type: "array", items: { type: "string", minLength: 1 } };
|
|
1802
1859
|
const escapePattern = (text) => text.replaceAll(/[.*+?^${}()|[\]\\/]/gu, "\\$&");
|
|
@@ -1832,7 +1889,98 @@ const PublicHostOptions = Schema.Struct({
|
|
|
1832
1889
|
hosts: Schema.Array(Schema.String),
|
|
1833
1890
|
allowedFiles: Schema.Array(Schema.String),
|
|
1834
1891
|
allowTestFiles: Schema.Boolean,
|
|
1892
|
+
allowTestAssertions: Schema.optionalKey(Schema.Boolean),
|
|
1893
|
+
allowedDeclarations: Schema.optionalKey(Schema.Array(Schema.Struct({ file: Schema.String, names: Schema.Array(Schema.String) }))),
|
|
1835
1894
|
});
|
|
1895
|
+
const topLevelConstant = (node) => {
|
|
1896
|
+
const parent = node.parent;
|
|
1897
|
+
if (!parent)
|
|
1898
|
+
return Option.none();
|
|
1899
|
+
if (parent.type === "ArrowFunctionExpression" ||
|
|
1900
|
+
parent.type === "FunctionExpression" ||
|
|
1901
|
+
parent.type === "FunctionDeclaration")
|
|
1902
|
+
return Option.none();
|
|
1903
|
+
if (parent.type !== "VariableDeclarator")
|
|
1904
|
+
return topLevelConstant(parent);
|
|
1905
|
+
if (parent.init !== node || parent.id.type !== "Identifier")
|
|
1906
|
+
return Option.none();
|
|
1907
|
+
const declaration = parent.parent;
|
|
1908
|
+
if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") {
|
|
1909
|
+
return Option.none();
|
|
1910
|
+
}
|
|
1911
|
+
const statement = declaration.parent;
|
|
1912
|
+
if (statement?.type === "Program" ||
|
|
1913
|
+
(statement?.type === "ExportNamedDeclaration" && statement.parent.type === "Program"))
|
|
1914
|
+
return Option.some(parent.id.name);
|
|
1915
|
+
return Option.none();
|
|
1916
|
+
};
|
|
1917
|
+
const expectedMatchers = [
|
|
1918
|
+
"toBe",
|
|
1919
|
+
"toEqual",
|
|
1920
|
+
"toStrictEqual",
|
|
1921
|
+
"toMatchObject",
|
|
1922
|
+
"toContain",
|
|
1923
|
+
"toContainEqual",
|
|
1924
|
+
"toMatch",
|
|
1925
|
+
];
|
|
1926
|
+
const expectedAssertions = [
|
|
1927
|
+
"equal",
|
|
1928
|
+
"notEqual",
|
|
1929
|
+
"strictEqual",
|
|
1930
|
+
"notStrictEqual",
|
|
1931
|
+
"deepEqual",
|
|
1932
|
+
"notDeepEqual",
|
|
1933
|
+
"deepStrictEqual",
|
|
1934
|
+
"notDeepStrictEqual",
|
|
1935
|
+
"include",
|
|
1936
|
+
"notInclude",
|
|
1937
|
+
"deepInclude",
|
|
1938
|
+
"notDeepInclude",
|
|
1939
|
+
"match",
|
|
1940
|
+
"notMatch",
|
|
1941
|
+
];
|
|
1942
|
+
const importedExpectation = (context, node) => {
|
|
1943
|
+
if (node.type === "CallExpression") {
|
|
1944
|
+
return Option.exists(testReference(context, node.callee), ({ path }) => (path.length === 1 && Array.contains(path, "expect")) ||
|
|
1945
|
+
(path.length === 2 &&
|
|
1946
|
+
Option.contains(Array.head(path), "expect") &&
|
|
1947
|
+
Option.contains(Array.get(path, 1), "soft")));
|
|
1948
|
+
}
|
|
1949
|
+
return (node.type === "MemberExpression" &&
|
|
1950
|
+
(!node.computed || node.property.type === "Literal") &&
|
|
1951
|
+
Option.exists(propertyName(node.property), (name) => Array.contains(["not", "resolves", "rejects"], name)) &&
|
|
1952
|
+
importedExpectation(context, node.object));
|
|
1953
|
+
};
|
|
1954
|
+
// Only static expected-value data is exempt. A call or constructor between a
|
|
1955
|
+
// literal and the assertion is setup/computation, even inside an expected arg.
|
|
1956
|
+
const assertionExpectedValue = (context, node) => {
|
|
1957
|
+
const parent = node.parent;
|
|
1958
|
+
if (!parent)
|
|
1959
|
+
return false;
|
|
1960
|
+
if (parent.type === "CallExpression") {
|
|
1961
|
+
const expected = Array.get(parent.arguments, 1);
|
|
1962
|
+
if (Option.exists(expected, (argument) => argument === node) &&
|
|
1963
|
+
Option.exists(testReference(context, parent.callee), ({ path }) => path.length === 2 &&
|
|
1964
|
+
Option.contains(Array.head(path), "assert") &&
|
|
1965
|
+
Option.exists(Array.get(path, 1), (name) => Array.contains(expectedAssertions, name))))
|
|
1966
|
+
return true;
|
|
1967
|
+
return (parent.callee.type === "MemberExpression" &&
|
|
1968
|
+
(!parent.callee.computed || parent.callee.property.type === "Literal") &&
|
|
1969
|
+
Option.exists(Array.head(parent.arguments), (argument) => argument === node) &&
|
|
1970
|
+
Option.exists(propertyName(parent.callee.property), (name) => Array.contains(expectedMatchers, name)) &&
|
|
1971
|
+
importedExpectation(context, parent.callee.object));
|
|
1972
|
+
}
|
|
1973
|
+
if (parent.type === "ArrayExpression" ||
|
|
1974
|
+
parent.type === "ObjectExpression" ||
|
|
1975
|
+
parent.type === "TemplateLiteral" ||
|
|
1976
|
+
(parent.type === "Property" &&
|
|
1977
|
+
parent.kind === "init" &&
|
|
1978
|
+
!parent.method &&
|
|
1979
|
+
(parent.value === node ||
|
|
1980
|
+
(parent.key === node && (!parent.computed || node.type === "Literal")))))
|
|
1981
|
+
return assertionExpectedValue(context, parent);
|
|
1982
|
+
return false;
|
|
1983
|
+
};
|
|
1836
1984
|
// A configured host or any subdomain of it, as a whole hostname.
|
|
1837
1985
|
const hostPattern = (host) => new RegExp(`(?:^|[^a-z0-9-])(?:[a-z0-9-]+\\.)*${escapePattern(host)}(?![a-z0-9-])`, "iu");
|
|
1838
1986
|
const publicHostLiterals = defineRule({
|
|
@@ -1849,6 +1997,16 @@ const publicHostLiterals = defineRule({
|
|
|
1849
1997
|
},
|
|
1850
1998
|
allowedFiles: ownerFilesSchema,
|
|
1851
1999
|
allowTestFiles: { type: "boolean" },
|
|
2000
|
+
allowTestAssertions: { type: "boolean" },
|
|
2001
|
+
allowedDeclarations: {
|
|
2002
|
+
type: "array",
|
|
2003
|
+
items: {
|
|
2004
|
+
type: "object",
|
|
2005
|
+
properties: { file: { type: "string", minLength: 1 }, names: ownerFilesSchema },
|
|
2006
|
+
required: ["file", "names"],
|
|
2007
|
+
additionalProperties: false,
|
|
2008
|
+
},
|
|
2009
|
+
},
|
|
1852
2010
|
},
|
|
1853
2011
|
required: ["hosts", "allowedFiles", "allowTestFiles"],
|
|
1854
2012
|
additionalProperties: false,
|
|
@@ -1860,16 +2018,24 @@ const publicHostLiterals = defineRule({
|
|
|
1860
2018
|
},
|
|
1861
2019
|
create(context) {
|
|
1862
2020
|
const options = Array.head(context.options).pipe(Option.flatMap(Schema.decodeUnknownOption(PublicHostOptions)));
|
|
1863
|
-
if (Option.isNone(options) ||
|
|
1864
|
-
ownedBy(context, options.value.allowedFiles) ||
|
|
1865
|
-
(options.value.allowTestFiles && testFile(context.filename))) {
|
|
2021
|
+
if (Option.isNone(options) || (options.value.allowTestFiles && testFile(context.filename))) {
|
|
1866
2022
|
return {};
|
|
1867
2023
|
}
|
|
2024
|
+
const declarationOwner = Array.findFirst(options.value.allowedDeclarations ?? [], ({ file }) => ownedBy(context, [file]));
|
|
2025
|
+
const fileOwner = ownedBy(context, options.value.allowedFiles);
|
|
2026
|
+
if (fileOwner && Option.isNone(declarationOwner))
|
|
2027
|
+
return {};
|
|
1868
2028
|
const patterns = Array.map(options.value.hosts, (host) => [
|
|
1869
2029
|
host,
|
|
1870
2030
|
hostPattern(host),
|
|
1871
2031
|
]);
|
|
1872
2032
|
return textVisitors((node, text) => {
|
|
2033
|
+
if ((fileOwner &&
|
|
2034
|
+
Option.exists(declarationOwner, ({ names }) => Option.exists(topLevelConstant(node), (name) => Array.contains(names, name)))) ||
|
|
2035
|
+
(options.value.allowTestAssertions === true &&
|
|
2036
|
+
testFile(context.filename) &&
|
|
2037
|
+
assertionExpectedValue(context, node)))
|
|
2038
|
+
return;
|
|
1873
2039
|
Option.match(Array.findFirst(patterns, ([, pattern]) => pattern.test(text)), {
|
|
1874
2040
|
onNone: Function.constVoid,
|
|
1875
2041
|
onSome: ([host]) => {
|
|
@@ -2357,6 +2523,7 @@ export default eslintCompatPlugin({
|
|
|
2357
2523
|
"no-native-client-replica": noNativeClientReplica,
|
|
2358
2524
|
"website-rpc-boundary": websiteRpcBoundary,
|
|
2359
2525
|
"static-layers": staticLayers,
|
|
2526
|
+
"subprocess-pipe-capture": subprocessPipeCapture,
|
|
2360
2527
|
"no-unchecked-index": noUncheckedIndex,
|
|
2361
2528
|
"no-native-at": noNativeAt,
|
|
2362
2529
|
"runtime-file-convention": runtimeFileConvention,
|
|
@@ -2368,6 +2535,7 @@ export default eslintCompatPlugin({
|
|
|
2368
2535
|
"no-unchecked-json": noUncheckedJson,
|
|
2369
2536
|
"no-runtime-outside-boundary": noRuntimeOutsideBoundary,
|
|
2370
2537
|
"no-native-work": noNativeWork,
|
|
2538
|
+
"no-runtime-type-assertions": noRuntimeTypeAssertions,
|
|
2371
2539
|
"no-imperative-collections": noImperativeCollections,
|
|
2372
2540
|
"public-host-literals": publicHostLiterals,
|
|
2373
2541
|
"route-literal-ownership": routeLiteralOwnership,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Context, ESTree } from "@oxlint/plugins";
|
|
2
|
+
import { Option } from "effect";
|
|
3
|
+
export type Channel = "exitCode" | "stdout" | "stderr";
|
|
4
|
+
export interface Capture {
|
|
5
|
+
readonly handle: ESTree.Node;
|
|
6
|
+
readonly channel: Channel;
|
|
7
|
+
}
|
|
8
|
+
export declare const memberName: (node: ESTree.MemberExpression) => Option.None<string> | Option.Some<string>;
|
|
9
|
+
export declare const nonPipe: (context: Context, handle: ESTree.Node, channel: Channel) => boolean;
|
|
10
|
+
export declare const channelMember: (context: Context, expression: ESTree.Node, visited?: readonly ESTree.Node[]) => Option.Option<Capture>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Array, Option } from "effect";
|
|
2
|
+
import { constant, operation, valueOf } from "./native-reference.js";
|
|
3
|
+
import { propertyName, variableInScope } from "./syntax.js";
|
|
4
|
+
export const memberName = (node) => node.computed && node.property.type !== "Literal" ? Option.none() : propertyName(node.property);
|
|
5
|
+
// A type annotation or a lookalike spawn method never establishes provider identity.
|
|
6
|
+
const processHandle = (context, expression) => valueOf(context, expression).pipe(Option.filter((node) => node.type === "YieldExpression"), Option.filter((node) => node.delegate), Option.flatMap((node) => Option.fromNullishOr(node.argument)), Option.filter((node) => node.type === "CallExpression"), Option.filter((node) => node.callee.type === "MemberExpression" &&
|
|
7
|
+
Option.contains(memberName(node.callee), "spawn")), Option.filter((node) => node.callee.type === "MemberExpression" &&
|
|
8
|
+
valueOf(context, node.callee.object).pipe(Option.filter((value) => value.type === "YieldExpression"), Option.filter((value) => value.delegate), Option.flatMap((value) => Option.fromNullishOr(value.argument)), Option.exists((service) => operation(context, service, "effect/process/ChildProcessSpawner", "ChildProcessSpawner")))));
|
|
9
|
+
const field = (context, expression, name) => valueOf(context, expression).pipe(Option.filter((node) => node.type === "ObjectExpression"), Option.filter((node) => Array.every(node.properties, (property) => property.type === "Property" &&
|
|
10
|
+
property.kind === "init" &&
|
|
11
|
+
!property.computed &&
|
|
12
|
+
!Option.contains(propertyName(property.key), "__proto__"))), Option.flatMap((node) => Array.findLast(node.properties, (property) => property.type === "Property" && Option.contains(propertyName(property.key), name))), Option.filter((property) => property.type === "Property"), Option.flatMap((property) => valueOf(context, property.value)));
|
|
13
|
+
export const nonPipe = (context, handle, channel) => {
|
|
14
|
+
if (channel === "exitCode" || handle.type !== "CallExpression")
|
|
15
|
+
return false;
|
|
16
|
+
const command = Array.head(handle.arguments).pipe(Option.flatMap((node) => valueOf(context, node)));
|
|
17
|
+
const options = command.pipe(Option.flatMap((node) => {
|
|
18
|
+
if (node.type === "TaggedTemplateExpression" &&
|
|
19
|
+
node.tag.type === "CallExpression" &&
|
|
20
|
+
operation(context, node.tag.callee, "effect/process/ChildProcess", "make"))
|
|
21
|
+
return Array.head(node.tag.arguments);
|
|
22
|
+
if (node.type !== "CallExpression" ||
|
|
23
|
+
!operation(context, node.callee, "effect/process/ChildProcess", "make"))
|
|
24
|
+
return Option.none();
|
|
25
|
+
return Array.get(node.arguments, 1).pipe(Option.flatMap((second) => valueOf(context, second)), Option.flatMap((second) => second.type === "ObjectExpression"
|
|
26
|
+
? Option.some(second)
|
|
27
|
+
: second.type === "ArrayExpression"
|
|
28
|
+
? Array.get(node.arguments, 2)
|
|
29
|
+
: Option.none()));
|
|
30
|
+
}));
|
|
31
|
+
return options.pipe(Option.flatMap((node) => field(context, node, channel)), Option.flatMap((node) => node.type === "ObjectExpression" ? field(context, node, "stream") : Option.some(node)), Option.exists((node) => node.type === "Literal" && (node.value === "inherit" || node.value === "ignore")));
|
|
32
|
+
};
|
|
33
|
+
export const channelMember = (context, expression, visited = []) => {
|
|
34
|
+
if (Array.some(visited, (node) => node === expression))
|
|
35
|
+
return Option.none();
|
|
36
|
+
const next = [...visited, expression];
|
|
37
|
+
if (expression.type === "Identifier") {
|
|
38
|
+
const declaration = variableInScope(context.sourceCode.getScope(expression), expression.name).pipe(Option.flatMap(constant));
|
|
39
|
+
return declaration.pipe(Option.flatMap((node) => {
|
|
40
|
+
if (node.id.type === "Identifier")
|
|
41
|
+
return Option.fromNullishOr(node.init).pipe(Option.flatMap((initialiser) => channelMember(context, initialiser, next)));
|
|
42
|
+
if (node.id.type !== "ObjectPattern" || !node.init)
|
|
43
|
+
return Option.none();
|
|
44
|
+
const handle = processHandle(context, node.init);
|
|
45
|
+
return Array.findFirst(node.id.properties, (property) => property.type === "Property" &&
|
|
46
|
+
!property.computed &&
|
|
47
|
+
property.value.type === "Identifier" &&
|
|
48
|
+
property.value.name === expression.name).pipe(Option.filter((property) => property.type === "Property"), Option.flatMap((property) => propertyName(property.key)), Option.filter((name) => name === "exitCode" || name === "stdout" || name === "stderr"), Option.flatMap((channel) => Option.map(handle, (process) => ({ handle: process, channel }))));
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
if (expression.type === "TSAsExpression" || expression.type === "TSSatisfiesExpression")
|
|
52
|
+
return channelMember(context, expression.expression, next);
|
|
53
|
+
if (expression.type !== "MemberExpression")
|
|
54
|
+
return Option.none();
|
|
55
|
+
return memberName(expression).pipe(Option.filter((name) => name === "exitCode" || name === "stdout" || name === "stderr"), Option.flatMap((channel) => Option.map(processHandle(context, expression.object), (handle) => ({ handle, channel }))));
|
|
56
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const subprocessPipeCapture: import("@oxlint/plugins").Rule;
|