@adamaho/nopeus-oxlint-plugin 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +822 -0
- package/dist/base.d.mts +31 -0
- package/dist/base.d.mts.map +1 -0
- package/dist/base.mjs +31 -0
- package/dist/base.mjs.map +1 -0
- package/dist/effect.d.mts +67 -0
- package/dist/effect.d.mts.map +1 -0
- package/dist/effect.mjs +64 -0
- package/dist/effect.mjs.map +1 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2693 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +72 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2693 @@
|
|
|
1
|
+
import { defineRule, eslintCompatPlugin } from "@oxlint/plugins";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { ResolverFactory } from "oxc-resolver";
|
|
4
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
5
|
+
//#region src/effect/rules/effect-call.ts
|
|
6
|
+
/** Describe the barrel and direct module imports for one Effect module. */
|
|
7
|
+
function moduleBindings(moduleName, barrelName) {
|
|
8
|
+
return {
|
|
9
|
+
barrelName,
|
|
10
|
+
moduleName
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function importedName$2(specifier) {
|
|
14
|
+
return specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
|
|
15
|
+
}
|
|
16
|
+
function resolveVariable$4(sourceCode, identifier) {
|
|
17
|
+
let scope = sourceCode.getScope(identifier);
|
|
18
|
+
while (scope !== null) {
|
|
19
|
+
const variable = scope.set.get(identifier.name);
|
|
20
|
+
if (variable !== void 0) return variable;
|
|
21
|
+
scope = scope.upper;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/** Test whether an identifier resolves to one global binding. */
|
|
26
|
+
function isGlobalIdentifier(sourceCode, identifier, name) {
|
|
27
|
+
if (identifier.name !== name) return false;
|
|
28
|
+
if (sourceCode.isGlobalReference(identifier)) return true;
|
|
29
|
+
const variable = resolveVariable$4(sourceCode, identifier);
|
|
30
|
+
return variable === null || variable.defs.length === 0;
|
|
31
|
+
}
|
|
32
|
+
function isNamedModuleImport(sourceCode, identifier, bindings, name) {
|
|
33
|
+
return resolveVariable$4(sourceCode, identifier)?.defs.some((definition) => definition.type === "ImportBinding" && definition.parent?.type === "ImportDeclaration" && definition.parent.source.value === bindings.moduleName && definition.node.type === "ImportSpecifier" && importedName$2(definition.node) === name) === true;
|
|
34
|
+
}
|
|
35
|
+
function isModuleNamespace(sourceCode, identifier, bindings) {
|
|
36
|
+
return resolveVariable$4(sourceCode, identifier)?.defs.some((definition) => {
|
|
37
|
+
if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") return false;
|
|
38
|
+
if (definition.parent.source.value === bindings.moduleName) return definition.node.type === "ImportNamespaceSpecifier";
|
|
39
|
+
return definition.parent.source.value === "effect" && definition.node.type === "ImportSpecifier" && importedName$2(definition.node) === bindings.barrelName;
|
|
40
|
+
}) === true;
|
|
41
|
+
}
|
|
42
|
+
function isBarrelModule(sourceCode, expression, bindings) {
|
|
43
|
+
if (expression.object.type !== "Identifier" || expression.computed || expression.property.type !== "Identifier" || expression.property.name !== bindings.barrelName) return false;
|
|
44
|
+
return resolveVariable$4(sourceCode, expression.object)?.defs.some((definition) => definition.type === "ImportBinding" && definition.parent?.type === "ImportDeclaration" && definition.parent.source.value === "effect" && definition.node.type === "ImportNamespaceSpecifier") === true;
|
|
45
|
+
}
|
|
46
|
+
/** Test whether a type name resolves to an imported Effect module type. */
|
|
47
|
+
function isModuleType(sourceCode, typeName, bindings, name) {
|
|
48
|
+
if (typeName.type === "Identifier") return isNamedModuleImport(sourceCode, typeName, bindings, name);
|
|
49
|
+
return typeName.type === "TSQualifiedName" && typeName.left.type === "Identifier" && isModuleNamespace(sourceCode, typeName.left, bindings) && typeName.right.name === name;
|
|
50
|
+
}
|
|
51
|
+
/** Test whether a callee resolves to an imported Effect module function. */
|
|
52
|
+
function isModuleCall(sourceCode, callee, bindings, name) {
|
|
53
|
+
if (callee.type === "Identifier") return isNamedModuleImport(sourceCode, callee, bindings, name);
|
|
54
|
+
return callee.type === "MemberExpression" && !callee.computed && (callee.object.type === "Identifier" && isModuleNamespace(sourceCode, callee.object, bindings) || callee.object.type === "MemberExpression" && isBarrelModule(sourceCode, callee.object, bindings)) && callee.property.type === "Identifier" && callee.property.name === name;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/effect/rules/no-effect-runners-in-library.ts
|
|
58
|
+
const runners = [
|
|
59
|
+
"runCallback",
|
|
60
|
+
"runCallbackWith",
|
|
61
|
+
"runFork",
|
|
62
|
+
"runForkWith",
|
|
63
|
+
"runPromise",
|
|
64
|
+
"runPromiseExit",
|
|
65
|
+
"runPromiseExitWith",
|
|
66
|
+
"runPromiseWith",
|
|
67
|
+
"runSync",
|
|
68
|
+
"runSyncExit",
|
|
69
|
+
"runSyncExitWith",
|
|
70
|
+
"runSyncWith"
|
|
71
|
+
];
|
|
72
|
+
function normalizedPath(path) {
|
|
73
|
+
return path.replaceAll("\\", "/");
|
|
74
|
+
}
|
|
75
|
+
function repositoryRelativePath(filename, cwd) {
|
|
76
|
+
const normalizedFilename = normalizedPath(filename);
|
|
77
|
+
const normalizedCwd = normalizedPath(cwd).replace(/\/$/u, "");
|
|
78
|
+
return normalizedFilename.startsWith(normalizedCwd + "/") ? normalizedFilename.slice(normalizedCwd.length + 1) : normalizedFilename;
|
|
79
|
+
}
|
|
80
|
+
function isAllowed(filename, cwd, allowFiles) {
|
|
81
|
+
const relativeFilename = repositoryRelativePath(filename, cwd);
|
|
82
|
+
return allowFiles.some((path) => relativeFilename === normalizedPath(path).replace(/^\.\//u, ""));
|
|
83
|
+
}
|
|
84
|
+
/** Keep Effect runtime execution in explicitly configured entrypoints. */
|
|
85
|
+
const noEffectRunnersInLibraryRule = defineRule({
|
|
86
|
+
meta: {
|
|
87
|
+
type: "problem",
|
|
88
|
+
docs: { description: "Disallow Effect runtime runners outside configured entrypoint files." },
|
|
89
|
+
schema: [{
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: { allowFiles: {
|
|
92
|
+
type: "array",
|
|
93
|
+
items: {
|
|
94
|
+
type: "string",
|
|
95
|
+
minLength: 1
|
|
96
|
+
},
|
|
97
|
+
uniqueItems: true
|
|
98
|
+
} },
|
|
99
|
+
required: ["allowFiles"],
|
|
100
|
+
additionalProperties: false
|
|
101
|
+
}],
|
|
102
|
+
defaultOptions: [{ allowFiles: [] }],
|
|
103
|
+
messages: { libraryRunner: "Run Effects only in a configured application entrypoint; return or compose this Effect instead." }
|
|
104
|
+
},
|
|
105
|
+
createOnce(context) {
|
|
106
|
+
const effect = moduleBindings("effect/Effect", "Effect");
|
|
107
|
+
return { CallExpression(node) {
|
|
108
|
+
const option = context.options?.[0];
|
|
109
|
+
const allowFiles = typeof option === "object" && option !== null && !Array.isArray(option) && Array.isArray(option.allowFiles) ? option.allowFiles.filter((value) => typeof value === "string") : [];
|
|
110
|
+
if (isAllowed(context.filename, context.cwd, allowFiles)) return;
|
|
111
|
+
if (runners.some((name) => isModuleCall(context.sourceCode, node.callee, effect, name))) context.report({
|
|
112
|
+
node: node.callee,
|
|
113
|
+
messageId: "libraryRunner"
|
|
114
|
+
});
|
|
115
|
+
} };
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/effect/rules/no-fallible-effect-promise.ts
|
|
120
|
+
/** Keep rejected promises in Effect's typed error channel. */
|
|
121
|
+
const noFallibleEffectPromiseRule = defineRule({
|
|
122
|
+
meta: {
|
|
123
|
+
type: "problem",
|
|
124
|
+
docs: { description: "Require Effect.tryPromise for promise-producing operations." },
|
|
125
|
+
messages: { useTryPromise: "Use Effect.tryPromise and map rejection into a domain error; Effect.promise turns rejection into a defect." }
|
|
126
|
+
},
|
|
127
|
+
createOnce(context) {
|
|
128
|
+
const effect = moduleBindings("effect/Effect", "Effect");
|
|
129
|
+
return { CallExpression(node) {
|
|
130
|
+
if (isModuleCall(context.sourceCode, node.callee, effect, "promise")) context.report({
|
|
131
|
+
node: node.callee,
|
|
132
|
+
messageId: "useTryPromise"
|
|
133
|
+
});
|
|
134
|
+
} };
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/effect/rules/no-inline-live-layer.ts
|
|
139
|
+
const liveConstructors = [
|
|
140
|
+
"effect",
|
|
141
|
+
"effectContext",
|
|
142
|
+
"effectDiscard",
|
|
143
|
+
"sync",
|
|
144
|
+
"syncContext",
|
|
145
|
+
"unwrap"
|
|
146
|
+
];
|
|
147
|
+
function unwrapExpression$1(expression) {
|
|
148
|
+
let current = expression;
|
|
149
|
+
while (current.type === "ParenthesizedExpression" || current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSTypeAssertion" || current.type === "TSNonNullExpression") current = current.expression;
|
|
150
|
+
return current;
|
|
151
|
+
}
|
|
152
|
+
function isInlineLayer(sourceCode, argument, layer) {
|
|
153
|
+
if (argument === void 0 || argument.type === "SpreadElement") return false;
|
|
154
|
+
const current = unwrapExpression$1(argument);
|
|
155
|
+
if (current.type !== "CallExpression") return false;
|
|
156
|
+
if (liveConstructors.some((name) => isModuleCall(sourceCode, current.callee, layer, name))) return true;
|
|
157
|
+
if (current.arguments.some((child) => isInlineLayer(sourceCode, child, layer))) return true;
|
|
158
|
+
const callee = current.callee;
|
|
159
|
+
return callee.type === "MemberExpression" && callee.object.type !== "Super" && isInlineLayer(sourceCode, callee.object, layer);
|
|
160
|
+
}
|
|
161
|
+
/** Keep live Layer construction at stable module composition boundaries. */
|
|
162
|
+
const noInlineLiveLayerRule = defineRule({
|
|
163
|
+
meta: {
|
|
164
|
+
type: "problem",
|
|
165
|
+
docs: { description: "Disallow constructing live Layers inside Effect.provide calls." },
|
|
166
|
+
messages: { extractLayer: "Extract this live Layer to a module-level binding and provide it at the application boundary." }
|
|
167
|
+
},
|
|
168
|
+
createOnce(context) {
|
|
169
|
+
const effect = moduleBindings("effect/Effect", "Effect");
|
|
170
|
+
const layer = moduleBindings("effect/Layer", "Layer");
|
|
171
|
+
return { CallExpression(node) {
|
|
172
|
+
if (!isModuleCall(context.sourceCode, node.callee, effect, "provide")) return;
|
|
173
|
+
if (node.arguments.some((argument) => isInlineLayer(context.sourceCode, argument, layer))) context.report({
|
|
174
|
+
node,
|
|
175
|
+
messageId: "extractLayer"
|
|
176
|
+
});
|
|
177
|
+
} };
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/effect/rules/no-module-level-mutable-state.ts
|
|
182
|
+
const collections = /* @__PURE__ */ new Set([
|
|
183
|
+
"Map",
|
|
184
|
+
"Set",
|
|
185
|
+
"WeakMap",
|
|
186
|
+
"WeakSet"
|
|
187
|
+
]);
|
|
188
|
+
function isModuleDeclaration(node) {
|
|
189
|
+
return node.parent.type === "Program" || node.parent.type === "ExportNamedDeclaration" && node.parent.parent.type === "Program";
|
|
190
|
+
}
|
|
191
|
+
function hasReadonlyContract(declarator, collection) {
|
|
192
|
+
const annotation = declarator.id.typeAnnotation?.typeAnnotation;
|
|
193
|
+
return (collection === "Map" || collection === "Set") && annotation?.type === "TSTypeReference" && annotation.typeName.type === "Identifier" && annotation.typeName.name === "Readonly" + collection;
|
|
194
|
+
}
|
|
195
|
+
function globalCollection(sourceCode, node) {
|
|
196
|
+
const callee = node.callee;
|
|
197
|
+
if (callee.type === "Identifier") return collections.has(callee.name) && isGlobalIdentifier(sourceCode, callee, callee.name) ? callee.name : null;
|
|
198
|
+
if (callee.type !== "MemberExpression" || callee.object.type !== "Identifier" || !isGlobalIdentifier(sourceCode, callee.object, "globalThis")) return null;
|
|
199
|
+
const property = callee.property;
|
|
200
|
+
const name = !callee.computed && property.type === "Identifier" ? property.name : property.type === "Literal" && typeof property.value === "string" ? property.value : null;
|
|
201
|
+
return name !== null && collections.has(name) ? name : null;
|
|
202
|
+
}
|
|
203
|
+
function unwrap$2(node) {
|
|
204
|
+
let current = node;
|
|
205
|
+
while (current.type === "ParenthesizedExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSAsExpression" || current.type === "TSTypeAssertion") current = current.expression;
|
|
206
|
+
return current;
|
|
207
|
+
}
|
|
208
|
+
/** Allocate service state during construction, allowing explicitly readonly lookup collections. */
|
|
209
|
+
const noModuleLevelMutableStateRule = defineRule({
|
|
210
|
+
meta: {
|
|
211
|
+
type: "problem",
|
|
212
|
+
docs: { description: "Disallow module-level mutable bindings and writable collection construction." },
|
|
213
|
+
messages: {
|
|
214
|
+
mutableBinding: "Allocate mutable state inside make so its lifetime belongs to the constructed service.",
|
|
215
|
+
mutableCollection: "Allocate this collection inside make. For an immutable lookup table, expose a ReadonlyMap or ReadonlySet contract."
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
createOnce(context) {
|
|
219
|
+
return { VariableDeclaration(node) {
|
|
220
|
+
if (!isModuleDeclaration(node) || node.declare) return;
|
|
221
|
+
if (node.kind === "let" || node.kind === "var") {
|
|
222
|
+
context.report({
|
|
223
|
+
node,
|
|
224
|
+
messageId: "mutableBinding"
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (const declarator of node.declarations) {
|
|
229
|
+
if (declarator.init === null) continue;
|
|
230
|
+
const value = unwrap$2(declarator.init);
|
|
231
|
+
if (value.type !== "NewExpression") continue;
|
|
232
|
+
const collection = globalCollection(context.sourceCode, value);
|
|
233
|
+
if (collection === null || hasReadonlyContract(declarator, collection)) continue;
|
|
234
|
+
context.report({
|
|
235
|
+
node: value,
|
|
236
|
+
messageId: "mutableCollection"
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
} };
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/effect/rules/no-unscoped-fork.ts
|
|
244
|
+
/** Keep background fibers attached to an explicit lifetime. */
|
|
245
|
+
const noUnscopedForkRule = defineRule({
|
|
246
|
+
meta: {
|
|
247
|
+
type: "problem",
|
|
248
|
+
docs: { description: "Require forkScoped or forkIn for background Effect fibers." },
|
|
249
|
+
messages: { scopedFork: "Use Effect.forkScoped or Effect.forkIn so the background fiber has an explicit lifetime." }
|
|
250
|
+
},
|
|
251
|
+
createOnce(context) {
|
|
252
|
+
const effect = moduleBindings("effect/Effect", "Effect");
|
|
253
|
+
return { CallExpression(node) {
|
|
254
|
+
if (isModuleCall(context.sourceCode, node.callee, effect, "forkDetach")) context.report({
|
|
255
|
+
node: node.callee,
|
|
256
|
+
messageId: "scopedFork"
|
|
257
|
+
});
|
|
258
|
+
} };
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region src/effect/rules/no-untyped-effect-errors.ts
|
|
263
|
+
const builtInErrors = /* @__PURE__ */ new Set([
|
|
264
|
+
"AggregateError",
|
|
265
|
+
"Error",
|
|
266
|
+
"EvalError",
|
|
267
|
+
"RangeError",
|
|
268
|
+
"ReferenceError",
|
|
269
|
+
"SyntaxError",
|
|
270
|
+
"TypeError",
|
|
271
|
+
"URIError"
|
|
272
|
+
]);
|
|
273
|
+
function unwrap$1(node) {
|
|
274
|
+
if (node === void 0 || node.type === "SpreadElement") return void 0;
|
|
275
|
+
let current = node;
|
|
276
|
+
while (current.type === "ParenthesizedExpression" || current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSTypeAssertion" || current.type === "TSNonNullExpression") current = current.expression;
|
|
277
|
+
return current;
|
|
278
|
+
}
|
|
279
|
+
function memberName(node) {
|
|
280
|
+
if (!node.computed && node.property.type === "Identifier") return node.property.name;
|
|
281
|
+
return node.computed && node.property.type === "Literal" && typeof node.property.value === "string" ? node.property.value : null;
|
|
282
|
+
}
|
|
283
|
+
function isGlobalBuiltInError(sourceCode, callee) {
|
|
284
|
+
if (callee.type === "Identifier") return builtInErrors.has(callee.name) && isGlobalIdentifier(sourceCode, callee, callee.name);
|
|
285
|
+
if (callee.type !== "MemberExpression" || callee.object.type !== "Identifier" || !isGlobalIdentifier(sourceCode, callee.object, "globalThis")) return false;
|
|
286
|
+
const name = memberName(callee);
|
|
287
|
+
return name !== null && builtInErrors.has(name);
|
|
288
|
+
}
|
|
289
|
+
function isUntypedError(sourceCode, node) {
|
|
290
|
+
const argument = unwrap$1(node);
|
|
291
|
+
if (argument === void 0) return false;
|
|
292
|
+
if (argument.type === "Literal" || argument.type === "TemplateLiteral" || argument.type === "ObjectExpression" || argument.type === "Identifier" && isGlobalIdentifier(sourceCode, argument, "undefined") || argument.type === "UnaryExpression" && argument.operator === "void") return true;
|
|
293
|
+
if (argument.type !== "NewExpression" && argument.type !== "CallExpression") return false;
|
|
294
|
+
const callee = argument.callee;
|
|
295
|
+
return callee.type !== "Super" && callee.type !== "V8IntrinsicExpression" && isGlobalBuiltInError(sourceCode, callee);
|
|
296
|
+
}
|
|
297
|
+
/** Keep expected failures in explicit domain error types. */
|
|
298
|
+
const noUntypedEffectErrorsRule = defineRule({
|
|
299
|
+
meta: {
|
|
300
|
+
type: "problem",
|
|
301
|
+
docs: { description: "Reject untyped values and built-in errors in Effect.fail." },
|
|
302
|
+
messages: { domainError: "Fail with a tagged domain error (for example Schema.TaggedError), not a primitive or built-in Error." }
|
|
303
|
+
},
|
|
304
|
+
createOnce(context) {
|
|
305
|
+
const effect = moduleBindings("effect/Effect", "Effect");
|
|
306
|
+
return { CallExpression(node) {
|
|
307
|
+
if (isModuleCall(context.sourceCode, node.callee, effect, "fail") && isUntypedError(context.sourceCode, node.arguments[0])) context.report({
|
|
308
|
+
node,
|
|
309
|
+
messageId: "domainError"
|
|
310
|
+
});
|
|
311
|
+
} };
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
//#endregion
|
|
315
|
+
//#region src/effect/rules/prefer-effect-platform-services.ts
|
|
316
|
+
const moduleReplacements = /* @__PURE__ */ new Map([
|
|
317
|
+
["fs", {
|
|
318
|
+
effect: "FileSystem.FileSystem",
|
|
319
|
+
provider: "NodeFileSystem.layer (or NodeServices.layer)"
|
|
320
|
+
}],
|
|
321
|
+
["fs/promises", {
|
|
322
|
+
effect: "FileSystem.FileSystem",
|
|
323
|
+
provider: "NodeFileSystem.layer (or NodeServices.layer)"
|
|
324
|
+
}],
|
|
325
|
+
["path", {
|
|
326
|
+
effect: "Path.Path",
|
|
327
|
+
provider: "NodePath.layer (or NodeServices.layer)"
|
|
328
|
+
}],
|
|
329
|
+
["child_process", {
|
|
330
|
+
effect: "ChildProcess commands and ChildProcessSpawner.ChildProcessSpawner",
|
|
331
|
+
provider: "NodeChildProcessSpawner.layer (or NodeServices.layer)"
|
|
332
|
+
}]
|
|
333
|
+
]);
|
|
334
|
+
const cryptoReplacements = /* @__PURE__ */ new Map([
|
|
335
|
+
["randomUUID", {
|
|
336
|
+
effect: "Crypto.Crypto.randomUUIDv4",
|
|
337
|
+
provider: "NodeCrypto.layer (or NodeServices.layer)"
|
|
338
|
+
}],
|
|
339
|
+
["randomUUIDv7", {
|
|
340
|
+
effect: "Crypto.Crypto.randomUUIDv7",
|
|
341
|
+
provider: "NodeCrypto.layer (or NodeServices.layer)"
|
|
342
|
+
}],
|
|
343
|
+
["randomBytes", {
|
|
344
|
+
effect: "Crypto.Crypto.randomBytes",
|
|
345
|
+
provider: "NodeCrypto.layer (or NodeServices.layer)"
|
|
346
|
+
}],
|
|
347
|
+
["randomInt", {
|
|
348
|
+
effect: "Crypto.Crypto.randomIntBetween",
|
|
349
|
+
provider: "NodeCrypto.layer (or NodeServices.layer)"
|
|
350
|
+
}],
|
|
351
|
+
["createHash", {
|
|
352
|
+
effect: "Crypto.Crypto.digest",
|
|
353
|
+
provider: "NodeCrypto.layer (or NodeServices.layer)"
|
|
354
|
+
}],
|
|
355
|
+
["hash", {
|
|
356
|
+
effect: "Crypto.Crypto.digest",
|
|
357
|
+
provider: "NodeCrypto.layer (or NodeServices.layer)"
|
|
358
|
+
}],
|
|
359
|
+
["subtle.digest", {
|
|
360
|
+
effect: "Crypto.Crypto.digest",
|
|
361
|
+
provider: "NodeCrypto.layer (or NodeServices.layer)"
|
|
362
|
+
}],
|
|
363
|
+
["webcrypto.subtle.digest", {
|
|
364
|
+
effect: "Crypto.Crypto.digest",
|
|
365
|
+
provider: "NodeCrypto.layer (or NodeServices.layer)"
|
|
366
|
+
}]
|
|
367
|
+
]);
|
|
368
|
+
const urlReplacements = /* @__PURE__ */ new Map([["fileURLToPath", {
|
|
369
|
+
effect: "Path.Path.fromFileUrl",
|
|
370
|
+
provider: "NodePath.layer (or NodeServices.layer)"
|
|
371
|
+
}], ["pathToFileURL", {
|
|
372
|
+
effect: "Path.Path.toFileUrl",
|
|
373
|
+
provider: "NodePath.layer (or NodeServices.layer)"
|
|
374
|
+
}]]);
|
|
375
|
+
const consoleReplacements = new Map([
|
|
376
|
+
"assert",
|
|
377
|
+
"clear",
|
|
378
|
+
"count",
|
|
379
|
+
"countReset",
|
|
380
|
+
"debug",
|
|
381
|
+
"dir",
|
|
382
|
+
"dirxml",
|
|
383
|
+
"error",
|
|
384
|
+
"group",
|
|
385
|
+
"groupCollapsed",
|
|
386
|
+
"groupEnd",
|
|
387
|
+
"info",
|
|
388
|
+
"log",
|
|
389
|
+
"table",
|
|
390
|
+
"time",
|
|
391
|
+
"timeEnd",
|
|
392
|
+
"timeLog",
|
|
393
|
+
"trace",
|
|
394
|
+
"warn"
|
|
395
|
+
].map((method) => [method, {
|
|
396
|
+
effect: `Console.Console.${method}`,
|
|
397
|
+
provider: "the runtime-provided Console.Console service"
|
|
398
|
+
}]));
|
|
399
|
+
const processReplacements = /* @__PURE__ */ new Map([
|
|
400
|
+
["argv", {
|
|
401
|
+
effect: "Stdio.Stdio.args",
|
|
402
|
+
provider: "NodeStdio.layer (or NodeServices.layer)"
|
|
403
|
+
}],
|
|
404
|
+
["stdin", {
|
|
405
|
+
effect: "Stdio.Stdio.stdin",
|
|
406
|
+
provider: "NodeStdio.layer (or NodeServices.layer)"
|
|
407
|
+
}],
|
|
408
|
+
["stdout", {
|
|
409
|
+
effect: "Stdio.Stdio.stdout",
|
|
410
|
+
provider: "NodeStdio.layer (or NodeServices.layer)"
|
|
411
|
+
}],
|
|
412
|
+
["stderr", {
|
|
413
|
+
effect: "Stdio.Stdio.stderr",
|
|
414
|
+
provider: "NodeStdio.layer (or NodeServices.layer)"
|
|
415
|
+
}],
|
|
416
|
+
["hrtime", {
|
|
417
|
+
effect: "Clock.Clock.monotonicTimeNanos",
|
|
418
|
+
provider: "the runtime-provided Clock.Clock service"
|
|
419
|
+
}]
|
|
420
|
+
]);
|
|
421
|
+
const timerReplacements = /* @__PURE__ */ new Map([["setTimeout", {
|
|
422
|
+
effect: "Effect.sleep",
|
|
423
|
+
provider: "the runtime-provided Clock.Clock service"
|
|
424
|
+
}], ["setInterval", {
|
|
425
|
+
effect: "Effect.repeat or Stream.fromEffectSchedule",
|
|
426
|
+
provider: "the runtime-provided Clock.Clock service"
|
|
427
|
+
}]]);
|
|
428
|
+
const httpReplacements = /* @__PURE__ */ new Map([
|
|
429
|
+
["get", {
|
|
430
|
+
effect: "HttpClient.get through HttpClient.HttpClient",
|
|
431
|
+
provider: "NodeHttpClient.layerUndici or NodeHttpClient.layerNodeHttp"
|
|
432
|
+
}],
|
|
433
|
+
["request", {
|
|
434
|
+
effect: "HttpClient.execute through HttpClient.HttpClient",
|
|
435
|
+
provider: "NodeHttpClient.layerUndici or NodeHttpClient.layerNodeHttp"
|
|
436
|
+
}],
|
|
437
|
+
["createServer", {
|
|
438
|
+
effect: "HttpServer.HttpServer for server behavior",
|
|
439
|
+
provider: "NodeHttpServer.layer or NodeHttpServer.layerConfig"
|
|
440
|
+
}]
|
|
441
|
+
]);
|
|
442
|
+
const symbolReplacements = /* @__PURE__ */ new Map([
|
|
443
|
+
["console", consoleReplacements],
|
|
444
|
+
["crypto", cryptoReplacements],
|
|
445
|
+
["http", httpReplacements],
|
|
446
|
+
["https", httpReplacements],
|
|
447
|
+
["net", /* @__PURE__ */ new Map([
|
|
448
|
+
["connect", {
|
|
449
|
+
effect: "Socket.Socket",
|
|
450
|
+
provider: "NodeSocket.makeNet or NodeSocket.layerNet"
|
|
451
|
+
}],
|
|
452
|
+
["createConnection", {
|
|
453
|
+
effect: "Socket.Socket",
|
|
454
|
+
provider: "NodeSocket.makeNet or NodeSocket.layerNet"
|
|
455
|
+
}],
|
|
456
|
+
["createServer", {
|
|
457
|
+
effect: "SocketServer.SocketServer",
|
|
458
|
+
provider: "NodeSocketServer.layer"
|
|
459
|
+
}]
|
|
460
|
+
])],
|
|
461
|
+
["perf_hooks", /* @__PURE__ */ new Map([["performance.now", {
|
|
462
|
+
effect: "Clock.Clock.monotonicTimeNanos",
|
|
463
|
+
provider: "the runtime-provided Clock.Clock service"
|
|
464
|
+
}]])],
|
|
465
|
+
["process", processReplacements],
|
|
466
|
+
["timers", timerReplacements],
|
|
467
|
+
["timers/promises", timerReplacements],
|
|
468
|
+
["url", urlReplacements]
|
|
469
|
+
]);
|
|
470
|
+
function builtinName(source) {
|
|
471
|
+
return source.startsWith("node:") ? source.slice(5) : source;
|
|
472
|
+
}
|
|
473
|
+
function importedName$1(specifier) {
|
|
474
|
+
return specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
|
|
475
|
+
}
|
|
476
|
+
function isValueSpecifier(declaration, specifier) {
|
|
477
|
+
return declaration.importKind !== "type" && (specifier.type !== "ImportSpecifier" || specifier.importKind !== "type");
|
|
478
|
+
}
|
|
479
|
+
function hasValueImport(node) {
|
|
480
|
+
return node.importKind !== "type" && (node.specifiers.length === 0 || node.specifiers.some((specifier) => isValueSpecifier(node, specifier)));
|
|
481
|
+
}
|
|
482
|
+
function resolveVariable$3(sourceCode, identifier) {
|
|
483
|
+
if (identifier.type !== "Identifier") return null;
|
|
484
|
+
let scope = sourceCode.getScope(identifier);
|
|
485
|
+
while (scope !== null) {
|
|
486
|
+
const variable = scope.set.get(identifier.name);
|
|
487
|
+
if (variable !== void 0) return variable;
|
|
488
|
+
scope = scope.upper;
|
|
489
|
+
}
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
function importBinding(sourceCode, identifier) {
|
|
493
|
+
const variable = resolveVariable$3(sourceCode, identifier);
|
|
494
|
+
for (const definition of variable?.defs ?? []) {
|
|
495
|
+
if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") continue;
|
|
496
|
+
const specifier = definition.node;
|
|
497
|
+
if (specifier.type !== "ImportSpecifier" && specifier.type !== "ImportDefaultSpecifier" && specifier.type !== "ImportNamespaceSpecifier") continue;
|
|
498
|
+
if (definition.parent.importKind === "type" || !isValueSpecifier(definition.parent, specifier)) continue;
|
|
499
|
+
if (specifier.type === "ImportNamespaceSpecifier") return {
|
|
500
|
+
imported: null,
|
|
501
|
+
source: definition.parent.source.value
|
|
502
|
+
};
|
|
503
|
+
if (specifier.type === "ImportDefaultSpecifier") return {
|
|
504
|
+
imported: "default",
|
|
505
|
+
source: definition.parent.source.value
|
|
506
|
+
};
|
|
507
|
+
return {
|
|
508
|
+
imported: importedName$1(specifier),
|
|
509
|
+
source: definition.parent.source.value
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
function propertyName$2(node) {
|
|
515
|
+
if (!node.computed && node.property.type === "Identifier") return node.property.name;
|
|
516
|
+
return node.property.type === "Literal" && typeof node.property.value === "string" ? node.property.value : null;
|
|
517
|
+
}
|
|
518
|
+
function importedMember(sourceCode, node) {
|
|
519
|
+
const path = [];
|
|
520
|
+
let current = node;
|
|
521
|
+
while (current.type === "MemberExpression") {
|
|
522
|
+
const name = propertyName$2(current);
|
|
523
|
+
if (name === null) return null;
|
|
524
|
+
path.unshift(name);
|
|
525
|
+
current = current.object;
|
|
526
|
+
}
|
|
527
|
+
if (current.type !== "Identifier") return null;
|
|
528
|
+
const binding = importBinding(sourceCode, current);
|
|
529
|
+
if (binding === null) return null;
|
|
530
|
+
if (binding.imported !== null && binding.imported !== "default") path.unshift(binding.imported);
|
|
531
|
+
return {
|
|
532
|
+
source: binding.source,
|
|
533
|
+
symbol: path.join(".")
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function isNodeHttpServerLayerCall(sourceCode, node) {
|
|
537
|
+
const callee = node.callee;
|
|
538
|
+
if (callee.type === "Identifier") {
|
|
539
|
+
const binding = importBinding(sourceCode, callee);
|
|
540
|
+
return binding !== null && binding.source === "@effect/platform-node/NodeHttpServer" && (binding.imported === "layer" || binding.imported === "layerConfig");
|
|
541
|
+
}
|
|
542
|
+
if (callee.type !== "MemberExpression" || callee.object.type !== "Identifier") return false;
|
|
543
|
+
const name = propertyName$2(callee);
|
|
544
|
+
if (name !== "layer" && name !== "layerConfig") return false;
|
|
545
|
+
const binding = importBinding(sourceCode, callee.object);
|
|
546
|
+
return binding !== null && (binding.source === "@effect/platform-node" && binding.imported === "NodeHttpServer" || binding.source === "@effect/platform-node/NodeHttpServer" && (binding.imported === null || binding.imported === "default"));
|
|
547
|
+
}
|
|
548
|
+
function isNodeHttpServerAdapterArgument(sourceCode, node) {
|
|
549
|
+
let current = node;
|
|
550
|
+
while (current.parent !== null && current.parent.type !== "Program") {
|
|
551
|
+
const parent = current.parent;
|
|
552
|
+
if (parent.type === "CallExpression" && parent.arguments[0] === current && isNodeHttpServerLayerCall(sourceCode, parent)) return true;
|
|
553
|
+
current = parent;
|
|
554
|
+
}
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
function isHttpCreateServer(source, symbol) {
|
|
558
|
+
const module = builtinName(source);
|
|
559
|
+
return (module === "http" || module === "https") && symbol === "createServer";
|
|
560
|
+
}
|
|
561
|
+
/** Keep platform I/O replaceable through Effect services. */
|
|
562
|
+
const preferEffectPlatformServicesRule = defineRule({
|
|
563
|
+
meta: {
|
|
564
|
+
type: "problem",
|
|
565
|
+
docs: { description: "Prefer Effect platform services over direct Node platform APIs." },
|
|
566
|
+
messages: { platformService: "Use {{effect}} (provided by {{provider}}) instead of {{api}} so platform behavior remains typed and replaceable." }
|
|
567
|
+
},
|
|
568
|
+
createOnce(context) {
|
|
569
|
+
const report = (node, source, symbol, replacement) => {
|
|
570
|
+
const api = symbol === null ? `the ${source} module` : `${source}.${symbol}`;
|
|
571
|
+
context.report({
|
|
572
|
+
node,
|
|
573
|
+
messageId: "platformService",
|
|
574
|
+
data: {
|
|
575
|
+
api,
|
|
576
|
+
effect: replacement.effect,
|
|
577
|
+
provider: replacement.provider
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
};
|
|
581
|
+
return {
|
|
582
|
+
ImportDeclaration(node) {
|
|
583
|
+
const module = builtinName(node.source.value);
|
|
584
|
+
const moduleReplacement = moduleReplacements.get(module);
|
|
585
|
+
if (moduleReplacement !== void 0 && hasValueImport(node)) {
|
|
586
|
+
report(node.source, node.source.value, null, moduleReplacement);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const replacements = symbolReplacements.get(module);
|
|
590
|
+
if (replacements === void 0 || node.importKind === "type") return;
|
|
591
|
+
for (const specifier of node.specifiers) {
|
|
592
|
+
if (specifier.type !== "ImportSpecifier" || !isValueSpecifier(node, specifier)) continue;
|
|
593
|
+
const symbol = importedName$1(specifier);
|
|
594
|
+
const replacement = replacements.get(symbol);
|
|
595
|
+
if (replacement === void 0 || symbol === "default" || isHttpCreateServer(node.source.value, symbol)) continue;
|
|
596
|
+
report(specifier, node.source.value, symbol, replacement);
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
MemberExpression(node) {
|
|
600
|
+
const member = importedMember(context.sourceCode, node);
|
|
601
|
+
if (member === null) return;
|
|
602
|
+
const replacement = symbolReplacements.get(builtinName(member.source))?.get(member.symbol);
|
|
603
|
+
if (replacement === void 0 || isHttpCreateServer(member.source, member.symbol) && isNodeHttpServerAdapterArgument(context.sourceCode, node)) return;
|
|
604
|
+
report(node, member.source, member.symbol, replacement);
|
|
605
|
+
},
|
|
606
|
+
Identifier(node) {
|
|
607
|
+
if (node.parent.type === "ImportSpecifier" || node.parent.type === "ImportDefaultSpecifier" || node.parent.type === "ImportNamespaceSpecifier") return;
|
|
608
|
+
const binding = importBinding(context.sourceCode, node);
|
|
609
|
+
if (binding === null || binding.imported === null || !isHttpCreateServer(binding.source, binding.imported) || isNodeHttpServerAdapterArgument(context.sourceCode, node)) return;
|
|
610
|
+
const replacement = symbolReplacements.get(builtinName(binding.source))?.get(binding.imported);
|
|
611
|
+
if (replacement !== void 0) report(node, binding.source, binding.imported, replacement);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
//#endregion
|
|
617
|
+
//#region src/effect/rules/prefer-effect-void.ts
|
|
618
|
+
function isUndefined(sourceCode, node) {
|
|
619
|
+
if (node === void 0 || node.type === "SpreadElement") return false;
|
|
620
|
+
if (node.type === "Identifier") return isGlobalIdentifier(sourceCode, node, "undefined");
|
|
621
|
+
return node.type === "UnaryExpression" && node.operator === "void" && node.argument.type === "Literal" && node.argument.value === 0;
|
|
622
|
+
}
|
|
623
|
+
/** Use the canonical Effect value for successful void results. */
|
|
624
|
+
const preferEffectVoidRule = defineRule({
|
|
625
|
+
meta: {
|
|
626
|
+
type: "suggestion",
|
|
627
|
+
docs: { description: "Prefer Effect.void over Effect.succeed(undefined)." },
|
|
628
|
+
messages: { preferVoid: "Use Effect.void for an Effect that succeeds with undefined." }
|
|
629
|
+
},
|
|
630
|
+
createOnce(context) {
|
|
631
|
+
const effect = moduleBindings("effect/Effect", "Effect");
|
|
632
|
+
return { CallExpression(node) {
|
|
633
|
+
if (isModuleCall(context.sourceCode, node.callee, effect, "succeed") && isUndefined(context.sourceCode, node.arguments[0])) context.report({
|
|
634
|
+
node,
|
|
635
|
+
messageId: "preferVoid"
|
|
636
|
+
});
|
|
637
|
+
} };
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region src/effect/rules/require-effect-fn-name.ts
|
|
642
|
+
function staticName(argument) {
|
|
643
|
+
if (argument === void 0 || argument.type === "SpreadElement") return null;
|
|
644
|
+
while (argument.type === "ParenthesizedExpression" || argument.type === "TSSatisfiesExpression" || argument.type === "TSAsExpression" || argument.type === "TSTypeAssertion") argument = argument.expression;
|
|
645
|
+
if (argument.type === "Literal") return typeof argument.value === "string" ? argument.value : null;
|
|
646
|
+
if (argument.type === "TemplateLiteral" && argument.expressions.length === 0) return argument.quasis[0]?.value.cooked ?? argument.quasis[0]?.value.raw ?? "";
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
function propertyName$1(key) {
|
|
650
|
+
if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name;
|
|
651
|
+
return key.type === "Literal" && typeof key.value === "string" ? key.value : null;
|
|
652
|
+
}
|
|
653
|
+
function ownerName(node) {
|
|
654
|
+
let current = node;
|
|
655
|
+
while (current.parent.type === "CallExpression" && current.parent.callee === current) current = current.parent;
|
|
656
|
+
const owner = current.parent;
|
|
657
|
+
if (owner.type === "VariableDeclarator" && owner.id.type === "Identifier") return owner.id.name;
|
|
658
|
+
if ((owner.type === "Property" || owner.type === "PropertyDefinition" || owner.type === "AccessorProperty") && owner.value === current) return propertyName$1(owner.key);
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
function nameMatchesOwner(name, owner) {
|
|
662
|
+
return name === owner || name.endsWith("." + owner) || name.endsWith("/" + owner);
|
|
663
|
+
}
|
|
664
|
+
/** Require traced Effect functions to carry a stable operation name. */
|
|
665
|
+
const requireEffectFnNameRule = defineRule({
|
|
666
|
+
meta: {
|
|
667
|
+
type: "problem",
|
|
668
|
+
docs: { description: "Require every Effect.fn call to provide a static operation name." },
|
|
669
|
+
messages: {
|
|
670
|
+
missingName: "Give this Effect.fn a static operation name so traces and diagnostics identify the workflow.",
|
|
671
|
+
mismatchedName: "Effect.fn name \"{{name}}\" must match its owning symbol \"{{owner}}\" or end with \".{{owner}}\" or \"/{{owner}}\"."
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
createOnce(context) {
|
|
675
|
+
return { CallExpression(node) {
|
|
676
|
+
const callee = node.callee;
|
|
677
|
+
if (!isModuleCall(context.sourceCode, callee, moduleBindings("effect/Effect", "Effect"), "fn")) return;
|
|
678
|
+
const name = staticName(node.arguments[0]);
|
|
679
|
+
if (name === null) {
|
|
680
|
+
context.report({
|
|
681
|
+
node: callee,
|
|
682
|
+
messageId: "missingName"
|
|
683
|
+
});
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const owner = ownerName(node);
|
|
687
|
+
if (owner !== null && !nameMatchesOwner(name, owner)) context.report({
|
|
688
|
+
node: node.arguments[0] ?? callee,
|
|
689
|
+
messageId: "mismatchedName",
|
|
690
|
+
data: {
|
|
691
|
+
name,
|
|
692
|
+
owner
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
} };
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
//#endregion
|
|
699
|
+
//#region src/effect/rules/require-effect-namespace.ts
|
|
700
|
+
function staticString$1(argument) {
|
|
701
|
+
if (argument === void 0 || argument.type === "SpreadElement") return null;
|
|
702
|
+
let current = argument;
|
|
703
|
+
while (current.type === "ParenthesizedExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSAsExpression" || current.type === "TSTypeAssertion") current = current.expression;
|
|
704
|
+
if (current.type === "Literal") return typeof current.value === "string" ? current.value : null;
|
|
705
|
+
if (current.type === "TemplateLiteral" && current.expressions.length === 0) return current.quasis[0]?.value.cooked ?? null;
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
const directNames = [
|
|
709
|
+
["Effect", "fn"],
|
|
710
|
+
["Effect", "makeSpan"],
|
|
711
|
+
["Effect", "makeSpanScoped"],
|
|
712
|
+
["Effect", "useSpan"],
|
|
713
|
+
["Layer", "span"]
|
|
714
|
+
];
|
|
715
|
+
const dualNames = [
|
|
716
|
+
["Effect", "withSpan"],
|
|
717
|
+
["Effect", "withSpanScoped"],
|
|
718
|
+
["Effect", "withLogSpan"],
|
|
719
|
+
["Channel", "withSpan"],
|
|
720
|
+
["RequestResolver", "withSpan"],
|
|
721
|
+
["Stream", "withSpan"],
|
|
722
|
+
["Layer", "withSpan"]
|
|
723
|
+
];
|
|
724
|
+
/** Require readable, repository-owned names for traced Effect operations. */
|
|
725
|
+
const requireEffectNamespaceRule = defineRule({
|
|
726
|
+
meta: {
|
|
727
|
+
type: "problem",
|
|
728
|
+
docs: { description: "Require static Effect trace names in @project/Domain.operation form." },
|
|
729
|
+
schema: [{
|
|
730
|
+
type: "object",
|
|
731
|
+
properties: { prefix: {
|
|
732
|
+
type: "string",
|
|
733
|
+
minLength: 1
|
|
734
|
+
} },
|
|
735
|
+
required: ["prefix"],
|
|
736
|
+
additionalProperties: false
|
|
737
|
+
}],
|
|
738
|
+
defaultOptions: [{ prefix: "@" }],
|
|
739
|
+
messages: {
|
|
740
|
+
invalidFormat: "{{api}} trace name \"{{key}}\" must use \"{{prefix}}Domain.operation\" with PascalCase domain segments and a camelCase operation.",
|
|
741
|
+
staticKey: "Give {{api}} a static trace name inside the repository namespace.",
|
|
742
|
+
wrongPrefix: "{{api}} trace name \"{{key}}\" must begin with the owned prefix \"{{prefix}}\" and include a name."
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
createOnce(context) {
|
|
746
|
+
const check = (node, index, api) => {
|
|
747
|
+
const argument = node.arguments[index];
|
|
748
|
+
const key = staticString$1(argument);
|
|
749
|
+
if (key === null) {
|
|
750
|
+
context.report({
|
|
751
|
+
node: argument ?? node,
|
|
752
|
+
messageId: "staticKey",
|
|
753
|
+
data: { api }
|
|
754
|
+
});
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const option = context.options[0];
|
|
758
|
+
const prefix = typeof option === "object" && option !== null && !Array.isArray(option) && typeof option.prefix === "string" ? option.prefix : "@";
|
|
759
|
+
if (key.startsWith(prefix) && key.length > prefix.length) {
|
|
760
|
+
if (/^(?:[A-Z][A-Za-z0-9]*\.)+[a-z][A-Za-z0-9]*$/u.test(key.slice(prefix.length))) return;
|
|
761
|
+
context.report({
|
|
762
|
+
node: argument ?? node,
|
|
763
|
+
messageId: "invalidFormat",
|
|
764
|
+
data: {
|
|
765
|
+
api,
|
|
766
|
+
key,
|
|
767
|
+
prefix
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
context.report({
|
|
773
|
+
node: argument ?? node,
|
|
774
|
+
messageId: "wrongPrefix",
|
|
775
|
+
data: {
|
|
776
|
+
api,
|
|
777
|
+
key,
|
|
778
|
+
prefix
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
};
|
|
782
|
+
return { CallExpression(node) {
|
|
783
|
+
const matches = (callee, module, name) => isModuleCall(context.sourceCode, callee, moduleBindings("effect/" + module, module), name);
|
|
784
|
+
for (const [module, name] of directNames) if (matches(node.callee, module, name)) {
|
|
785
|
+
check(node, 0, module + "." + name);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
for (const [module, name] of dualNames) if (matches(node.callee, module, name)) {
|
|
789
|
+
const index = staticString$1(node.arguments[0]) !== null ? 0 : staticString$1(node.arguments[1]) !== null || node.arguments.length >= 3 ? 1 : 0;
|
|
790
|
+
check(node, index, module + "." + name);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
} };
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
//#endregion
|
|
797
|
+
//#region src/effect/rules/require-fetch-abort-signal.ts
|
|
798
|
+
function propertyName(node) {
|
|
799
|
+
const key = node.type === "Property" ? node.key : node.property;
|
|
800
|
+
if (!node.computed && key.type === "Identifier") return key.name;
|
|
801
|
+
return key.type === "Literal" && typeof key.value === "string" ? key.value : null;
|
|
802
|
+
}
|
|
803
|
+
function isFetch(sourceCode, callee) {
|
|
804
|
+
if (callee.type === "Identifier") return isGlobalIdentifier(sourceCode, callee, "fetch");
|
|
805
|
+
return callee.type === "MemberExpression" && callee.object.type === "Identifier" && isGlobalIdentifier(sourceCode, callee.object, "globalThis") && propertyName(callee) === "fetch";
|
|
806
|
+
}
|
|
807
|
+
function tryPromiseCallback(sourceCode, node) {
|
|
808
|
+
let current = node.parent;
|
|
809
|
+
while (current !== null && current.type !== "Program") {
|
|
810
|
+
if (current.type === "FunctionDeclaration") return null;
|
|
811
|
+
if (current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression") {
|
|
812
|
+
let owner = current;
|
|
813
|
+
if (owner.parent.type === "Property" && owner.parent.value === owner && propertyName(owner.parent) === "try" && owner.parent.parent.type === "ObjectExpression") owner = owner.parent.parent;
|
|
814
|
+
const parent = owner.parent;
|
|
815
|
+
return parent.type === "CallExpression" && parent.arguments[0] === owner && isModuleCall(sourceCode, parent.callee, moduleBindings("effect/Effect", "Effect"), "tryPromise") ? current : null;
|
|
816
|
+
}
|
|
817
|
+
current = current.parent;
|
|
818
|
+
}
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
function isCallbackSignal(sourceCode, value, callback) {
|
|
822
|
+
const parameter = callback.params[0];
|
|
823
|
+
if (parameter?.type !== "Identifier" || value.type !== "Identifier") return false;
|
|
824
|
+
let scope = sourceCode.getScope(value);
|
|
825
|
+
while (true) {
|
|
826
|
+
const variable = scope.set.get(value.name);
|
|
827
|
+
if (variable !== void 0) return variable.defs.some((definition) => definition.type === "Parameter" && definition.name === parameter);
|
|
828
|
+
if (scope.upper === null) return false;
|
|
829
|
+
scope = scope.upper;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
function forwardsSignal(sourceCode, options, callback) {
|
|
833
|
+
if (options?.type !== "ObjectExpression") return false;
|
|
834
|
+
for (let index = options.properties.length - 1; index >= 0; index--) {
|
|
835
|
+
const property = options.properties[index];
|
|
836
|
+
if (property === void 0 || property.type === "SpreadElement") return false;
|
|
837
|
+
const name = propertyName(property);
|
|
838
|
+
if (name === null) return false;
|
|
839
|
+
if (name === "signal") return isCallbackSignal(sourceCode, property.value, callback);
|
|
840
|
+
}
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
/** Forward Effect interruption to global fetch at a direct Promise adapter boundary. */
|
|
844
|
+
const requireFetchAbortSignalRule = defineRule({
|
|
845
|
+
meta: {
|
|
846
|
+
type: "problem",
|
|
847
|
+
docs: { description: "Require global fetch in Effect.tryPromise callbacks to forward their AbortSignal." },
|
|
848
|
+
messages: { missingSignal: "Forward the Effect.tryPromise callback's signal with fetch(url, { ...options, signal }) so interruption cancels the request." }
|
|
849
|
+
},
|
|
850
|
+
createOnce(context) {
|
|
851
|
+
return { CallExpression(node) {
|
|
852
|
+
if (!isFetch(context.sourceCode, node.callee)) return;
|
|
853
|
+
const callback = tryPromiseCallback(context.sourceCode, node);
|
|
854
|
+
if (callback === null || forwardsSignal(context.sourceCode, node.arguments[1], callback)) return;
|
|
855
|
+
context.report({
|
|
856
|
+
node,
|
|
857
|
+
messageId: "missingSignal"
|
|
858
|
+
});
|
|
859
|
+
} };
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region src/effect/rules/require-service-constructor-names.ts
|
|
864
|
+
const layerMethods = [
|
|
865
|
+
"effect",
|
|
866
|
+
"effectContext",
|
|
867
|
+
"effectDiscard",
|
|
868
|
+
"succeed",
|
|
869
|
+
"succeedContext",
|
|
870
|
+
"sync",
|
|
871
|
+
"syncContext",
|
|
872
|
+
"unwrap",
|
|
873
|
+
"suspend",
|
|
874
|
+
"merge",
|
|
875
|
+
"mergeAll",
|
|
876
|
+
"fresh",
|
|
877
|
+
"orDie"
|
|
878
|
+
];
|
|
879
|
+
const layerCombinators = [
|
|
880
|
+
"provide",
|
|
881
|
+
"provideMerge",
|
|
882
|
+
"catch",
|
|
883
|
+
"catchCause"
|
|
884
|
+
];
|
|
885
|
+
function isFactory(node) {
|
|
886
|
+
return node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression";
|
|
887
|
+
}
|
|
888
|
+
function unwrap(node) {
|
|
889
|
+
let current = node;
|
|
890
|
+
while (current.type === "ParenthesizedExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSAsExpression" || current.type === "TSNonNullExpression" || current.type === "TSTypeAssertion") current = current.expression;
|
|
891
|
+
return current;
|
|
892
|
+
}
|
|
893
|
+
function exportName(node) {
|
|
894
|
+
return node.type === "Identifier" ? node.name : node.value;
|
|
895
|
+
}
|
|
896
|
+
/** Name public service constructors consistently without requiring paired exports. */
|
|
897
|
+
const requireServiceConstructorNamesRule = defineRule({
|
|
898
|
+
meta: {
|
|
899
|
+
type: "suggestion",
|
|
900
|
+
docs: { description: "Name exported Layers layer/layerX and service constructors make/makeX." },
|
|
901
|
+
messages: {
|
|
902
|
+
layer: "Name this exported Layer or Layer factory layer or layerX (for example layerConfig).",
|
|
903
|
+
make: "Name this exported service constructor make or makeX (for example makeMemory)."
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
createOnce(context) {
|
|
907
|
+
const layer = moduleBindings("effect/Layer", "Layer");
|
|
908
|
+
const effect = moduleBindings("effect/Effect", "Effect");
|
|
909
|
+
const service = moduleBindings("effect/Context", "Context");
|
|
910
|
+
const services = /* @__PURE__ */ new Set();
|
|
911
|
+
const interfaces = /* @__PURE__ */ new Set();
|
|
912
|
+
const constructors = /* @__PURE__ */ new Set();
|
|
913
|
+
const returns = /* @__PURE__ */ new Map();
|
|
914
|
+
const exports = [];
|
|
915
|
+
function variable(node) {
|
|
916
|
+
let scope = context.sourceCode.getScope(node);
|
|
917
|
+
while (true) {
|
|
918
|
+
const found = scope.set.get(node.name);
|
|
919
|
+
if (found !== void 0) return found;
|
|
920
|
+
if (scope.upper === null) return void 0;
|
|
921
|
+
scope = scope.upper;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function hasBinding(bindings, node) {
|
|
925
|
+
const binding = variable(node);
|
|
926
|
+
return binding !== void 0 && bindings.has(binding);
|
|
927
|
+
}
|
|
928
|
+
function serviceCall(expression) {
|
|
929
|
+
if (expression === null) return null;
|
|
930
|
+
const node = unwrap(expression);
|
|
931
|
+
if (node.type !== "CallExpression") return null;
|
|
932
|
+
const call = node.callee.type === "CallExpression" ? node.callee : node;
|
|
933
|
+
return isModuleCall(context.sourceCode, call.callee, service, "Service") ? call : null;
|
|
934
|
+
}
|
|
935
|
+
function registerService(id, call) {
|
|
936
|
+
const binding = variable(id);
|
|
937
|
+
if (binding !== void 0) services.add(binding);
|
|
938
|
+
if (id.parent.type === "ClassDeclaration") for (const declared of context.sourceCode.getDeclaredVariables(id.parent)) services.add(declared);
|
|
939
|
+
const shape = call.typeArguments?.params.at(-1);
|
|
940
|
+
if (shape?.type === "TSTypeReference" && shape.typeName.type === "Identifier") {
|
|
941
|
+
const shapeBinding = variable(shape.typeName);
|
|
942
|
+
if (shapeBinding !== void 0) interfaces.add(shapeBinding);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
function typeKind(type) {
|
|
946
|
+
if (type?.type !== "TSTypeReference") return null;
|
|
947
|
+
if (isModuleType(context.sourceCode, type.typeName, layer, "Layer")) return "layer";
|
|
948
|
+
if (isModuleType(context.sourceCode, type.typeName, effect, "Effect")) return typeKind(type.typeArguments?.params[0]);
|
|
949
|
+
return type.typeName.type === "Identifier" && hasBinding(interfaces, type.typeName) ? "make" : null;
|
|
950
|
+
}
|
|
951
|
+
function kind(node, seen = /* @__PURE__ */ new Set()) {
|
|
952
|
+
if (seen.has(node)) return null;
|
|
953
|
+
const visited = new Set(seen).add(node);
|
|
954
|
+
if (node.type === "VariableDeclarator") return (node.id.type === "Identifier" ? typeKind(node.id.typeAnnotation?.typeAnnotation) ?? (hasBinding(constructors, node.id) ? "make" : null) : null) ?? (node.init === null ? null : kind(node.init, visited));
|
|
955
|
+
if (isFactory(node)) return typeKind(node.returnType?.typeAnnotation) ?? (node.type !== "ArrowFunctionExpression" && node.id !== null && hasBinding(constructors, node.id) ? "make" : null) ?? (node.body !== null && node.body.type !== "BlockStatement" ? kind(node.body, visited) : (returns.get(node) ?? []).map((value) => kind(value, visited)).find((value) => value !== null) ?? null);
|
|
956
|
+
if (node.type === "Identifier") {
|
|
957
|
+
const binding = variable(node);
|
|
958
|
+
if (binding === void 0 || services.has(binding)) return null;
|
|
959
|
+
if (constructors.has(binding)) return "make";
|
|
960
|
+
for (const definition of binding.defs) if (definition.type !== "ImportBinding") {
|
|
961
|
+
const result = kind(definition.node, visited);
|
|
962
|
+
if (result !== null) return result;
|
|
963
|
+
}
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
if (node.type === "ParenthesizedExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSAsExpression" || node.type === "TSTypeAssertion" || node.type === "TSNonNullExpression") return kind(node.expression, visited);
|
|
967
|
+
if (node.type === "AwaitExpression") return kind(node.argument, visited);
|
|
968
|
+
if (node.type === "YieldExpression") return node.argument === null ? null : kind(node.argument, visited);
|
|
969
|
+
if (node.type === "ConditionalExpression") return kind(node.consequent, visited) ?? kind(node.alternate, visited);
|
|
970
|
+
if (node.type === "MemberExpression" && isModuleCall(context.sourceCode, node, layer, "empty")) return "layer";
|
|
971
|
+
if (node.type !== "CallExpression") return null;
|
|
972
|
+
const call = node.callee.type === "CallExpression" ? node.callee : node;
|
|
973
|
+
if (layerMethods.some((name) => isModuleCall(context.sourceCode, call.callee, layer, name))) return "layer";
|
|
974
|
+
if ((call !== node || node.arguments.length >= 2) && layerCombinators.some((name) => isModuleCall(context.sourceCode, call.callee, layer, name))) return "layer";
|
|
975
|
+
if (node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.property.type === "Identifier") {
|
|
976
|
+
const receiver = node.callee.object;
|
|
977
|
+
if (node.callee.property.name === "of" && receiver.type === "Identifier" && hasBinding(services, receiver)) return "make";
|
|
978
|
+
if (node.callee.property.name === "pipe" && receiver.type !== "Super") {
|
|
979
|
+
const last = node.arguments.at(-1);
|
|
980
|
+
if (last?.type === "CallExpression" && [
|
|
981
|
+
"map",
|
|
982
|
+
"flatMap",
|
|
983
|
+
"as",
|
|
984
|
+
"asVoid"
|
|
985
|
+
].some((name) => isModuleCall(context.sourceCode, last.callee, effect, name))) return kind(last, visited);
|
|
986
|
+
return kind(receiver, visited);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
if ([
|
|
990
|
+
"gen",
|
|
991
|
+
"fn",
|
|
992
|
+
"fnUntraced",
|
|
993
|
+
"sync",
|
|
994
|
+
"succeed",
|
|
995
|
+
"map",
|
|
996
|
+
"flatMap",
|
|
997
|
+
"suspend"
|
|
998
|
+
].some((name) => isModuleCall(context.sourceCode, call.callee, effect, name))) {
|
|
999
|
+
const result = node.arguments.filter((arg) => arg.type !== "SpreadElement").at(-1);
|
|
1000
|
+
return result === void 0 ? null : kind(result, visited);
|
|
1001
|
+
}
|
|
1002
|
+
return node.callee.type === "Identifier" ? kind(node.callee, visited) : null;
|
|
1003
|
+
}
|
|
1004
|
+
function registerConstructor(expression) {
|
|
1005
|
+
const node = unwrap(expression);
|
|
1006
|
+
if (node.type === "Identifier") {
|
|
1007
|
+
const binding = variable(node);
|
|
1008
|
+
if (binding !== void 0 && binding.defs.some((def) => def.type !== "ImportBinding")) constructors.add(binding);
|
|
1009
|
+
} else if (node.type === "CallExpression" && node.callee.type === "Identifier") registerConstructor(node.callee);
|
|
1010
|
+
else if (isFactory(node)) {
|
|
1011
|
+
if (node.body !== null && node.body.type !== "BlockStatement") registerConstructor(node.body);
|
|
1012
|
+
else for (const value of returns.get(node) ?? []) registerConstructor(value);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return {
|
|
1016
|
+
Program() {
|
|
1017
|
+
services.clear();
|
|
1018
|
+
interfaces.clear();
|
|
1019
|
+
constructors.clear();
|
|
1020
|
+
returns.clear();
|
|
1021
|
+
exports.length = 0;
|
|
1022
|
+
},
|
|
1023
|
+
ClassDeclaration(node) {
|
|
1024
|
+
const call = serviceCall(node.superClass);
|
|
1025
|
+
if (node.id !== null && call !== null) registerService(node.id, call);
|
|
1026
|
+
},
|
|
1027
|
+
VariableDeclarator(node) {
|
|
1028
|
+
const call = serviceCall(node.init);
|
|
1029
|
+
if (node.id.type === "Identifier" && call !== null) registerService(node.id, call);
|
|
1030
|
+
},
|
|
1031
|
+
ReturnStatement(node) {
|
|
1032
|
+
if (node.argument === null) return;
|
|
1033
|
+
let parent = node.parent;
|
|
1034
|
+
while (parent !== null && !isFactory(parent)) parent = parent.parent;
|
|
1035
|
+
if (parent === null) return;
|
|
1036
|
+
const values = returns.get(parent) ?? [];
|
|
1037
|
+
values.push(node.argument);
|
|
1038
|
+
returns.set(parent, values);
|
|
1039
|
+
},
|
|
1040
|
+
"CallExpression:exit"(node) {
|
|
1041
|
+
const call = node.callee.type === "CallExpression" ? node.callee : node;
|
|
1042
|
+
if (![
|
|
1043
|
+
"effect",
|
|
1044
|
+
"succeed",
|
|
1045
|
+
"sync"
|
|
1046
|
+
].some((name) => isModuleCall(context.sourceCode, call.callee, layer, name))) return;
|
|
1047
|
+
const construction = node.arguments[call === node ? 1 : 0];
|
|
1048
|
+
if (construction !== void 0 && construction.type !== "SpreadElement") registerConstructor(construction);
|
|
1049
|
+
},
|
|
1050
|
+
ExportNamedDeclaration(node) {
|
|
1051
|
+
if (node.exportKind === "type" || node.source !== null) return;
|
|
1052
|
+
const declaration = node.declaration;
|
|
1053
|
+
if (declaration?.type === "VariableDeclaration") {
|
|
1054
|
+
for (const item of declaration.declarations) if (item.id.type === "Identifier") exports.push({
|
|
1055
|
+
name: item.id.name,
|
|
1056
|
+
node: item.id,
|
|
1057
|
+
value: item
|
|
1058
|
+
});
|
|
1059
|
+
} else if (declaration?.type === "FunctionDeclaration" && declaration.id !== null) exports.push({
|
|
1060
|
+
name: declaration.id.name,
|
|
1061
|
+
node: declaration.id,
|
|
1062
|
+
value: declaration
|
|
1063
|
+
});
|
|
1064
|
+
for (const specifier of node.specifiers) if (specifier.type === "ExportSpecifier" && specifier.exportKind !== "type") exports.push({
|
|
1065
|
+
name: exportName(specifier.exported),
|
|
1066
|
+
node: specifier.exported,
|
|
1067
|
+
value: specifier.local
|
|
1068
|
+
});
|
|
1069
|
+
},
|
|
1070
|
+
ExportDefaultDeclaration(node) {
|
|
1071
|
+
exports.push({
|
|
1072
|
+
name: "default",
|
|
1073
|
+
node,
|
|
1074
|
+
value: node.declaration
|
|
1075
|
+
});
|
|
1076
|
+
},
|
|
1077
|
+
"Program:exit"() {
|
|
1078
|
+
for (const entry of exports) {
|
|
1079
|
+
const result = kind(entry.value);
|
|
1080
|
+
if (result === null) continue;
|
|
1081
|
+
if (!(result === "layer" ? /^layer(?:[A-Z][a-zA-Z0-9]*)?$/u : /^make(?:[A-Z][a-zA-Z0-9]*)?$/u).test(entry.name)) context.report({
|
|
1082
|
+
node: entry.node,
|
|
1083
|
+
messageId: result
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
//#endregion
|
|
1091
|
+
//#region src/effect/rules/require-service-key-prefix.ts
|
|
1092
|
+
function staticString(argument) {
|
|
1093
|
+
if (argument === void 0 || argument.type === "SpreadElement") return null;
|
|
1094
|
+
let current = argument;
|
|
1095
|
+
while (current.type === "ParenthesizedExpression" || current.type === "TSSatisfiesExpression") current = current.expression;
|
|
1096
|
+
if (current.type === "Literal") return typeof current.value === "string" ? current.value : null;
|
|
1097
|
+
if (current.type === "TemplateLiteral" && current.expressions.length === 0) return current.quasis[0]?.value.cooked ?? current.quasis[0]?.value.raw ?? "";
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
/** Keep Effect service identifiers inside the repository's owned namespace. */
|
|
1101
|
+
const requireServiceKeyPrefixRule = defineRule({
|
|
1102
|
+
meta: {
|
|
1103
|
+
type: "problem",
|
|
1104
|
+
docs: { description: "Require Context.Service and Context.Reference keys to use a configured static prefix." },
|
|
1105
|
+
schema: [{
|
|
1106
|
+
type: "object",
|
|
1107
|
+
properties: { prefix: {
|
|
1108
|
+
type: "string",
|
|
1109
|
+
minLength: 1
|
|
1110
|
+
} },
|
|
1111
|
+
required: ["prefix"],
|
|
1112
|
+
additionalProperties: false
|
|
1113
|
+
}],
|
|
1114
|
+
defaultOptions: [{ prefix: "@" }],
|
|
1115
|
+
messages: {
|
|
1116
|
+
staticKey: "Give this Context service/reference a static string key inside the repository namespace.",
|
|
1117
|
+
wrongPrefix: "Service key \"{{key}}\" must begin with the owned prefix \"{{prefix}}\" and include a name."
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1120
|
+
createOnce(context) {
|
|
1121
|
+
const checkKey = (node, keyArgument) => {
|
|
1122
|
+
const key = staticString(keyArgument);
|
|
1123
|
+
if (key === null) {
|
|
1124
|
+
context.report({
|
|
1125
|
+
node,
|
|
1126
|
+
messageId: "staticKey"
|
|
1127
|
+
});
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const option = context.options?.[0];
|
|
1131
|
+
const prefix = typeof option === "object" && option !== null && !Array.isArray(option) && typeof option.prefix === "string" ? option.prefix : "@";
|
|
1132
|
+
if (key.startsWith(prefix) && key.length > prefix.length) return;
|
|
1133
|
+
context.report({
|
|
1134
|
+
node: keyArgument ?? node,
|
|
1135
|
+
messageId: "wrongPrefix",
|
|
1136
|
+
data: {
|
|
1137
|
+
key,
|
|
1138
|
+
prefix
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
};
|
|
1142
|
+
return { CallExpression(node) {
|
|
1143
|
+
const matches = (callee, name) => isModuleCall(context.sourceCode, callee, moduleBindings("effect/Context", "Context"), name);
|
|
1144
|
+
if (matches(node.callee, "Reference")) {
|
|
1145
|
+
checkKey(node, node.arguments[0]);
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (node.callee.type === "CallExpression" && matches(node.callee.callee, "Service") && node.callee.arguments.length === 0) {
|
|
1149
|
+
checkKey(node, node.arguments[0]);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
if (!matches(node.callee, "Service")) return;
|
|
1153
|
+
if (node.arguments.length === 0 && node.parent.type === "CallExpression" && node.parent.callee === node) return;
|
|
1154
|
+
checkKey(node, node.arguments[0]);
|
|
1155
|
+
} };
|
|
1156
|
+
}
|
|
1157
|
+
});
|
|
1158
|
+
//#endregion
|
|
1159
|
+
//#region src/rules/no-conditional-empty-object-spread.ts
|
|
1160
|
+
function unwrapParentheses(node) {
|
|
1161
|
+
let current = node;
|
|
1162
|
+
while (current.type === "ParenthesizedExpression") current = current.expression;
|
|
1163
|
+
return current;
|
|
1164
|
+
}
|
|
1165
|
+
function isEmptyObjectExpression$1(node) {
|
|
1166
|
+
return node.type === "ObjectExpression" && node.properties.length === 0;
|
|
1167
|
+
}
|
|
1168
|
+
function isConditionalEmptyObjectSpread(node) {
|
|
1169
|
+
const conditional = unwrapParentheses(node);
|
|
1170
|
+
return conditional.type === "ConditionalExpression" && (isEmptyObjectExpression$1(conditional.consequent) || isEmptyObjectExpression$1(conditional.alternate));
|
|
1171
|
+
}
|
|
1172
|
+
/** Ban conditional empty-object spreads without changing their omission semantics. */
|
|
1173
|
+
const noConditionalEmptyObjectSpreadRule = defineRule({
|
|
1174
|
+
meta: {
|
|
1175
|
+
type: "suggestion",
|
|
1176
|
+
docs: { description: "Disallow object spreads that conditionally spread an empty object to omit fields." },
|
|
1177
|
+
messages: { avoid: "This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present." }
|
|
1178
|
+
},
|
|
1179
|
+
createOnce(context) {
|
|
1180
|
+
return { SpreadElement(node) {
|
|
1181
|
+
if (node.parent.type !== "ObjectExpression") return;
|
|
1182
|
+
if (isConditionalEmptyObjectSpread(node.argument)) context.report({
|
|
1183
|
+
node,
|
|
1184
|
+
messageId: "avoid"
|
|
1185
|
+
});
|
|
1186
|
+
} };
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
//#endregion
|
|
1190
|
+
//#region src/shared/package-layout.ts
|
|
1191
|
+
/** Normalize existing symlinks without requiring editor buffers to exist on disk. */
|
|
1192
|
+
function physicalPath(filename) {
|
|
1193
|
+
const absolute = resolve(filename);
|
|
1194
|
+
return existsSync(absolute) ? realpathSync(absolute) : absolute;
|
|
1195
|
+
}
|
|
1196
|
+
/** Find the package that owns a source file or an unsaved editor buffer. */
|
|
1197
|
+
function packageOwner(filename) {
|
|
1198
|
+
let directory = dirname(physicalPath(filename));
|
|
1199
|
+
while (true) {
|
|
1200
|
+
const manifest = join(directory, "package.json");
|
|
1201
|
+
if (existsSync(manifest)) {
|
|
1202
|
+
const value = JSON.parse(readFileSync(manifest, "utf8"));
|
|
1203
|
+
if (!((value?.type === "module" || value?.type === "commonjs") && Object.keys(value).length === 1)) return {
|
|
1204
|
+
root: directory,
|
|
1205
|
+
name: typeof value?.name === "string" ? value.name : null,
|
|
1206
|
+
hasExports: value !== null && Object.hasOwn(value, "exports")
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
const parent = dirname(directory);
|
|
1210
|
+
if (parent === directory) return null;
|
|
1211
|
+
directory = parent;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
/** Return a portable package-relative path, including for Windows hosts. */
|
|
1215
|
+
function packagePath(owner, filename) {
|
|
1216
|
+
return relative(owner.root, physicalPath(filename)).split(sep).join("/");
|
|
1217
|
+
}
|
|
1218
|
+
/** Recognize test/spec suffixes on supported JavaScript and TypeScript files. */
|
|
1219
|
+
function isTestFile(filename) {
|
|
1220
|
+
return /\.(?:test|spec)\.(?:[cm]?[jt]s|[jt]sx)$/u.test(filename);
|
|
1221
|
+
}
|
|
1222
|
+
/** Include test resources and legacy test locations in the production boundary. */
|
|
1223
|
+
function isTestPath(path) {
|
|
1224
|
+
return path.startsWith("test/") || path.startsWith("tests/") || path.split("/").includes("__tests__") || isTestFile(path);
|
|
1225
|
+
}
|
|
1226
|
+
//#endregion
|
|
1227
|
+
//#region src/shared/import-resolution.ts
|
|
1228
|
+
/** Resolve TS paths and Node package exports with the same conditions. */
|
|
1229
|
+
function importResolution() {
|
|
1230
|
+
const options = {
|
|
1231
|
+
extensions: [
|
|
1232
|
+
".ts",
|
|
1233
|
+
".tsx",
|
|
1234
|
+
".mts",
|
|
1235
|
+
".cts",
|
|
1236
|
+
".js",
|
|
1237
|
+
".jsx",
|
|
1238
|
+
".mjs",
|
|
1239
|
+
".cjs",
|
|
1240
|
+
".json"
|
|
1241
|
+
],
|
|
1242
|
+
extensionAlias: {
|
|
1243
|
+
".js": [
|
|
1244
|
+
".ts",
|
|
1245
|
+
".tsx",
|
|
1246
|
+
".js"
|
|
1247
|
+
],
|
|
1248
|
+
".mjs": [".mts", ".mjs"],
|
|
1249
|
+
".cjs": [".cts", ".cjs"]
|
|
1250
|
+
},
|
|
1251
|
+
conditionNames: [
|
|
1252
|
+
"types",
|
|
1253
|
+
"import",
|
|
1254
|
+
"node",
|
|
1255
|
+
"default"
|
|
1256
|
+
],
|
|
1257
|
+
builtinModules: true
|
|
1258
|
+
};
|
|
1259
|
+
const resolver = new ResolverFactory({
|
|
1260
|
+
...options,
|
|
1261
|
+
tsconfig: "auto"
|
|
1262
|
+
});
|
|
1263
|
+
const internals = resolver.cloneWithOptions({
|
|
1264
|
+
...options,
|
|
1265
|
+
tsconfig: "auto",
|
|
1266
|
+
exportsFields: []
|
|
1267
|
+
});
|
|
1268
|
+
return {
|
|
1269
|
+
target(filename, specifier) {
|
|
1270
|
+
const resolved = resolver.resolveFileSync(filename, specifier);
|
|
1271
|
+
if (resolved.builtin) return null;
|
|
1272
|
+
if (resolved.path) return physicalPath(resolved.path);
|
|
1273
|
+
const fallback = internals.resolveFileSync(filename, specifier);
|
|
1274
|
+
if (fallback.path) return physicalPath(fallback.path);
|
|
1275
|
+
if (resolved.error?.includes("is not exported")) {
|
|
1276
|
+
const name = specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
|
|
1277
|
+
const manifest = internals.sync(dirname(filename), name + "/package.json");
|
|
1278
|
+
if (manifest.path) return physicalPath(manifest.path);
|
|
1279
|
+
}
|
|
1280
|
+
if (specifier.startsWith(".") || isAbsolute(specifier)) return physicalPath(resolve(dirname(filename), specifier));
|
|
1281
|
+
return null;
|
|
1282
|
+
},
|
|
1283
|
+
publicTarget(filename, specifier) {
|
|
1284
|
+
const result = resolver.sync(dirname(filename), specifier);
|
|
1285
|
+
return result.path ? physicalPath(result.path) : null;
|
|
1286
|
+
}
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
//#endregion
|
|
1290
|
+
//#region src/shared/import-sources.ts
|
|
1291
|
+
function isUnshadowedRequire(sourceCode, node) {
|
|
1292
|
+
let scope = sourceCode.getScope(node);
|
|
1293
|
+
while (true) {
|
|
1294
|
+
const variable = scope.set.get("require");
|
|
1295
|
+
if (variable) return variable.defs.length === 0;
|
|
1296
|
+
if (!scope.upper) return true;
|
|
1297
|
+
scope = scope.upper;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
/** Visit literal module references, including re-exports and type-only imports. */
|
|
1301
|
+
function importSources(sourceCode, check) {
|
|
1302
|
+
return {
|
|
1303
|
+
TSImportType(node) {
|
|
1304
|
+
check(node.source, node.source.value);
|
|
1305
|
+
},
|
|
1306
|
+
TSExternalModuleReference(node) {
|
|
1307
|
+
check(node.expression, node.expression.value);
|
|
1308
|
+
},
|
|
1309
|
+
ImportDeclaration(node) {
|
|
1310
|
+
check(node.source, node.source.value);
|
|
1311
|
+
},
|
|
1312
|
+
ExportNamedDeclaration(node) {
|
|
1313
|
+
if (node.source) check(node.source, node.source.value);
|
|
1314
|
+
},
|
|
1315
|
+
ExportAllDeclaration(node) {
|
|
1316
|
+
check(node.source, node.source.value);
|
|
1317
|
+
},
|
|
1318
|
+
ImportExpression(node) {
|
|
1319
|
+
if (node.source.type === "Literal" && typeof node.source.value === "string") check(node.source, node.source.value);
|
|
1320
|
+
},
|
|
1321
|
+
CallExpression(node) {
|
|
1322
|
+
const first = node.arguments[0];
|
|
1323
|
+
if (node.callee.type === "Identifier" && node.callee.name === "require" && isUnshadowedRequire(sourceCode(), node.callee) && first?.type === "Literal" && typeof first.value === "string") check(first, first.value);
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
//#endregion
|
|
1328
|
+
//#region src/rules/no-cross-package-internals.ts
|
|
1329
|
+
/** Require imports across package boundaries to use the target's public API. */
|
|
1330
|
+
const noCrossPackageInternalsRule = defineRule({
|
|
1331
|
+
meta: {
|
|
1332
|
+
type: "problem",
|
|
1333
|
+
docs: { description: "Disallow cross-package filesystem imports and private deep imports." },
|
|
1334
|
+
schema: [],
|
|
1335
|
+
messages: { boundary: "Import {{package}} through its package name and public exports, not another package's files." }
|
|
1336
|
+
},
|
|
1337
|
+
create(context) {
|
|
1338
|
+
const filename = physicalPath(context.filename);
|
|
1339
|
+
const owner = packageOwner(filename);
|
|
1340
|
+
if (!owner) return {};
|
|
1341
|
+
const resolution = importResolution();
|
|
1342
|
+
return importSources(() => context.sourceCode, (node, specifier) => {
|
|
1343
|
+
const target = resolution.target(filename, specifier);
|
|
1344
|
+
if (!target) return;
|
|
1345
|
+
const destination = packageOwner(target);
|
|
1346
|
+
if (!destination || destination.root === owner.root) return;
|
|
1347
|
+
const name = destination.name;
|
|
1348
|
+
if (name && (specifier === name || destination.hasExports && specifier.startsWith(name + "/")) && resolution.publicTarget(filename, specifier) === target) return;
|
|
1349
|
+
context.report({
|
|
1350
|
+
node,
|
|
1351
|
+
messageId: "boundary",
|
|
1352
|
+
data: { package: name ?? destination.root }
|
|
1353
|
+
});
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
//#endregion
|
|
1358
|
+
//#region src/rules/no-export-assignment.ts
|
|
1359
|
+
/** Reject the TypeScript CommonJS export form not covered by import/no-commonjs. */
|
|
1360
|
+
const noExportAssignmentRule = defineRule({
|
|
1361
|
+
meta: {
|
|
1362
|
+
type: "problem",
|
|
1363
|
+
docs: { description: "Require ESM exports instead of TypeScript export assignments." },
|
|
1364
|
+
schema: [],
|
|
1365
|
+
messages: { commonjs: "Use ESM export syntax instead of CommonJS export =." }
|
|
1366
|
+
},
|
|
1367
|
+
createOnce(context) {
|
|
1368
|
+
return { TSExportAssignment(node) {
|
|
1369
|
+
context.report({
|
|
1370
|
+
node,
|
|
1371
|
+
messageId: "commonjs"
|
|
1372
|
+
});
|
|
1373
|
+
} };
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
//#endregion
|
|
1377
|
+
//#region src/shared/dictionary-types.ts
|
|
1378
|
+
const BUILT_INS = /* @__PURE__ */ new Set([
|
|
1379
|
+
"Record",
|
|
1380
|
+
"Readonly",
|
|
1381
|
+
"Partial",
|
|
1382
|
+
"Required",
|
|
1383
|
+
"Pick",
|
|
1384
|
+
"Omit",
|
|
1385
|
+
"PropertyKey",
|
|
1386
|
+
"NonNullable"
|
|
1387
|
+
]);
|
|
1388
|
+
const TRANSPARENT_WRAPPERS = /* @__PURE__ */ new Set([
|
|
1389
|
+
"Readonly",
|
|
1390
|
+
"Partial",
|
|
1391
|
+
"Required",
|
|
1392
|
+
"NonNullable"
|
|
1393
|
+
]);
|
|
1394
|
+
function declaredStatement(statement) {
|
|
1395
|
+
return statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration ?? null : statement;
|
|
1396
|
+
}
|
|
1397
|
+
function createTypeEnvironment(program) {
|
|
1398
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1399
|
+
const interfaces = /* @__PURE__ */ new Map();
|
|
1400
|
+
const shadowedBuiltIns = /* @__PURE__ */ new Set();
|
|
1401
|
+
for (const statement of program.body) {
|
|
1402
|
+
const declaration = declaredStatement(statement);
|
|
1403
|
+
if (declaration?.type === "ImportDeclaration") {
|
|
1404
|
+
for (const specifier of declaration.specifiers) if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name);
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
if (declaration?.type === "TSTypeAliasDeclaration") {
|
|
1408
|
+
if (aliases.get(declaration.id.name) === void 0) aliases.set(declaration.id.name, declaration);
|
|
1409
|
+
else shadowedBuiltIns.add(declaration.id.name);
|
|
1410
|
+
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
if (declaration?.type === "TSInterfaceDeclaration") {
|
|
1414
|
+
const declarations = interfaces.get(declaration.id.name) ?? [];
|
|
1415
|
+
declarations.push(declaration);
|
|
1416
|
+
interfaces.set(declaration.id.name, declarations);
|
|
1417
|
+
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
if (declaration?.type === "TSEnumDeclaration") {
|
|
1421
|
+
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
if ((declaration?.type === "ClassDeclaration" || declaration?.type === "FunctionDeclaration") && declaration.id !== null) {
|
|
1425
|
+
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
return {
|
|
1429
|
+
aliases,
|
|
1430
|
+
interfaces,
|
|
1431
|
+
shadowedBuiltIns
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
function typeReferenceName$1(type) {
|
|
1435
|
+
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
|
1436
|
+
}
|
|
1437
|
+
function isBuiltIn(name, environment) {
|
|
1438
|
+
return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name);
|
|
1439
|
+
}
|
|
1440
|
+
function isUnappliedReferenceTo(type, name) {
|
|
1441
|
+
const unwrapped = unwrapTransparentType(type);
|
|
1442
|
+
return unwrapped.type === "TSTypeReference" && typeReferenceName$1(unwrapped) === name && (unwrapped.typeArguments === null || unwrapped.typeArguments === void 0 || unwrapped.typeArguments.params.length === 0);
|
|
1443
|
+
}
|
|
1444
|
+
function unwrapTransparentType(type) {
|
|
1445
|
+
let current = type;
|
|
1446
|
+
while (current.type === "TSParenthesizedType" || current.type === "TSTypeOperator" && current.operator === "readonly") current = current.typeAnnotation;
|
|
1447
|
+
return current;
|
|
1448
|
+
}
|
|
1449
|
+
function isNeverType(type) {
|
|
1450
|
+
return unwrapTransparentType(type).type === "TSNeverKeyword";
|
|
1451
|
+
}
|
|
1452
|
+
function isEffectivelyEmptyMember(member) {
|
|
1453
|
+
return member.type === "TSPropertySignature" && member.optional === true && member.typeAnnotation !== null && member.typeAnnotation !== void 0 && isNeverType(member.typeAnnotation.typeAnnotation);
|
|
1454
|
+
}
|
|
1455
|
+
function isEffectivelyEmptyTypeLiteral(type) {
|
|
1456
|
+
return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember);
|
|
1457
|
+
}
|
|
1458
|
+
function isEffectivelyEmptyInterface(declarations) {
|
|
1459
|
+
if (declarations.length !== 1) return false;
|
|
1460
|
+
const [type] = declarations;
|
|
1461
|
+
return type !== void 0 && type.extends.length === 0 && (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember));
|
|
1462
|
+
}
|
|
1463
|
+
function resolvedSubstitutionArgument(type, base, resolving = /* @__PURE__ */ new Set()) {
|
|
1464
|
+
const unwrapped = unwrapTransparentType(type);
|
|
1465
|
+
if (unwrapped.type !== "TSTypeReference") return type;
|
|
1466
|
+
const name = typeReferenceName$1(unwrapped);
|
|
1467
|
+
if (name === null || resolving.has(name)) return type;
|
|
1468
|
+
const substitution = base.get(name);
|
|
1469
|
+
if (substitution === void 0) return type;
|
|
1470
|
+
const nextResolving = new Set(resolving);
|
|
1471
|
+
nextResolving.add(name);
|
|
1472
|
+
return resolvedSubstitutionArgument(substitution, base, nextResolving);
|
|
1473
|
+
}
|
|
1474
|
+
function aliasSubstitution(alias, type, base) {
|
|
1475
|
+
const parameters = alias.typeParameters?.params ?? [];
|
|
1476
|
+
const arguments_ = type.typeArguments?.params ?? [];
|
|
1477
|
+
const next = new Map(base);
|
|
1478
|
+
for (const [index, parameter] of parameters.entries()) {
|
|
1479
|
+
const argument = arguments_[index] ?? parameter.default;
|
|
1480
|
+
if (argument === null || argument === void 0) return null;
|
|
1481
|
+
next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next));
|
|
1482
|
+
}
|
|
1483
|
+
return next;
|
|
1484
|
+
}
|
|
1485
|
+
function unsafeDirectValue(type, environment, substitutions, resolvingAliases) {
|
|
1486
|
+
const unwrapped = unwrapTransparentType(type);
|
|
1487
|
+
if (unwrapped.type === "TSUnknownKeyword") return "unknown";
|
|
1488
|
+
if (unwrapped.type === "TSAnyKeyword") return "any";
|
|
1489
|
+
if (unwrapped.type === "TSObjectKeyword") return "object";
|
|
1490
|
+
if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) return "empty-object";
|
|
1491
|
+
if (unwrapped.type === "TSUnionType") return unwrapped.types.some((member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null) ? "union" : null;
|
|
1492
|
+
if (unwrapped.type === "TSIntersectionType") {
|
|
1493
|
+
const unsafeMembers = unwrapped.types.map((member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases));
|
|
1494
|
+
if (unsafeMembers.includes("any")) return "any";
|
|
1495
|
+
const [firstUnsafeMember] = unsafeMembers;
|
|
1496
|
+
return firstUnsafeMember !== void 0 && unsafeMembers.every((member) => member !== null) ? firstUnsafeMember : null;
|
|
1497
|
+
}
|
|
1498
|
+
if (unwrapped.type !== "TSTypeReference") return null;
|
|
1499
|
+
const name = typeReferenceName$1(unwrapped);
|
|
1500
|
+
if (name === null) return null;
|
|
1501
|
+
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
|
|
1502
|
+
const wrapped = unwrapped.typeArguments?.params[0];
|
|
1503
|
+
return wrapped === void 0 ? null : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases);
|
|
1504
|
+
}
|
|
1505
|
+
const substitution = substitutions.get(name);
|
|
1506
|
+
if (substitution !== void 0) return isUnappliedReferenceTo(substitution, name) ? null : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases);
|
|
1507
|
+
const interfaceDeclarations = environment.interfaces.get(name);
|
|
1508
|
+
if (interfaceDeclarations !== void 0) return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null;
|
|
1509
|
+
const alias = environment.aliases.get(name);
|
|
1510
|
+
if (alias === void 0 || resolvingAliases.has(name)) return null;
|
|
1511
|
+
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
|
1512
|
+
if (nextSubstitutions === null) return null;
|
|
1513
|
+
const nextResolving = new Set(resolvingAliases);
|
|
1514
|
+
nextResolving.add(name);
|
|
1515
|
+
return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
|
|
1516
|
+
}
|
|
1517
|
+
function dictionaryValueTypes(type, environment, substitutions, resolvingAliases) {
|
|
1518
|
+
const unwrapped = unwrapTransparentType(type);
|
|
1519
|
+
if (unwrapped.type === "TSTypeLiteral") return unwrapped.members.flatMap((member) => member.type === "TSIndexSignature" && member.typeAnnotation !== null ? [{
|
|
1520
|
+
type: member.typeAnnotation.typeAnnotation,
|
|
1521
|
+
substitutions
|
|
1522
|
+
}] : []);
|
|
1523
|
+
if (unwrapped.type === "TSMappedType") return unwrapped.typeAnnotation === null ? [] : [{
|
|
1524
|
+
type: unwrapped.typeAnnotation,
|
|
1525
|
+
substitutions
|
|
1526
|
+
}];
|
|
1527
|
+
if (unwrapped.type !== "TSTypeReference") return [];
|
|
1528
|
+
const name = typeReferenceName$1(unwrapped);
|
|
1529
|
+
if (name === null) return [];
|
|
1530
|
+
const substitution = substitutions.get(name);
|
|
1531
|
+
if (substitution !== void 0) return isUnappliedReferenceTo(substitution, name) ? [] : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases);
|
|
1532
|
+
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
|
|
1533
|
+
const wrapped = unwrapped.typeArguments?.params[0];
|
|
1534
|
+
return wrapped === void 0 ? [] : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases);
|
|
1535
|
+
}
|
|
1536
|
+
if (name === "Record" && isBuiltIn(name, environment)) {
|
|
1537
|
+
const value = unwrapped.typeArguments?.params[1] ?? null;
|
|
1538
|
+
return value === null ? [] : [{
|
|
1539
|
+
type: value,
|
|
1540
|
+
substitutions
|
|
1541
|
+
}];
|
|
1542
|
+
}
|
|
1543
|
+
if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) {
|
|
1544
|
+
const source = unwrapped.typeArguments?.params[0];
|
|
1545
|
+
return source === void 0 ? [] : dictionaryValueTypes(source, environment, substitutions, resolvingAliases);
|
|
1546
|
+
}
|
|
1547
|
+
const alias = environment.aliases.get(name);
|
|
1548
|
+
if (alias === void 0 || resolvingAliases.has(name)) return [];
|
|
1549
|
+
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
|
1550
|
+
if (nextSubstitutions === null) return [];
|
|
1551
|
+
const nextResolving = new Set(resolvingAliases);
|
|
1552
|
+
nextResolving.add(name);
|
|
1553
|
+
return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
|
|
1554
|
+
}
|
|
1555
|
+
function classifyUnsafeDictionaryValue(valueType, environment) {
|
|
1556
|
+
const unsafeValue = unsafeDirectValue(valueType, environment, /* @__PURE__ */ new Map(), /* @__PURE__ */ new Set());
|
|
1557
|
+
return unsafeValue === null ? null : {
|
|
1558
|
+
kind: "unsafe-dictionary",
|
|
1559
|
+
unsafeValue
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
function classifyUnsafeDictionary(type, environment) {
|
|
1563
|
+
for (const valueType of dictionaryValueTypes(type, environment, /* @__PURE__ */ new Map(), /* @__PURE__ */ new Set())) {
|
|
1564
|
+
const unsafeValue = unsafeDirectValue(valueType.type, environment, valueType.substitutions, /* @__PURE__ */ new Set());
|
|
1565
|
+
if (unsafeValue !== null) return {
|
|
1566
|
+
kind: "unsafe-dictionary",
|
|
1567
|
+
unsafeValue
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
return null;
|
|
1571
|
+
}
|
|
1572
|
+
function resolvesToDictionary(type, environment, substitutions, resolvingAliases) {
|
|
1573
|
+
return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0;
|
|
1574
|
+
}
|
|
1575
|
+
function classifyWideningTarget(type, environment) {
|
|
1576
|
+
const unwrapped = unwrapTransparentType(type);
|
|
1577
|
+
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
|
|
1578
|
+
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
|
|
1579
|
+
if (unwrapped.type === "TSTypeLiteral") return unwrapped.members.some((member) => member.type === "TSIndexSignature") ? { kind: "open dictionary" } : unwrapped.members.length > 0 ? { kind: "anonymous object" } : null;
|
|
1580
|
+
if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" };
|
|
1581
|
+
if (unwrapped.type !== "TSTypeReference") return null;
|
|
1582
|
+
const name = typeReferenceName$1(unwrapped);
|
|
1583
|
+
if (name === null) return null;
|
|
1584
|
+
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
|
|
1585
|
+
const wrapped = unwrapped.typeArguments?.params[0];
|
|
1586
|
+
return wrapped === void 0 ? null : classifyWideningTarget(wrapped, environment);
|
|
1587
|
+
}
|
|
1588
|
+
if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" };
|
|
1589
|
+
const alias = environment.aliases.get(name);
|
|
1590
|
+
if (alias === void 0) return null;
|
|
1591
|
+
if ((alias.typeParameters?.params.length ?? 0) > 0) {
|
|
1592
|
+
const substitutions = aliasSubstitution(alias, unwrapped, /* @__PURE__ */ new Map());
|
|
1593
|
+
return substitutions !== null && resolvesToDictionary(alias.typeAnnotation, environment, substitutions, /* @__PURE__ */ new Set([name])) ? { kind: "generic container" } : null;
|
|
1594
|
+
}
|
|
1595
|
+
const substitutions = aliasSubstitution(alias, unwrapped, /* @__PURE__ */ new Map());
|
|
1596
|
+
if (substitutions === null) return null;
|
|
1597
|
+
return classifyAliasBroadTarget(alias.typeAnnotation, environment, substitutions, /* @__PURE__ */ new Set([name]));
|
|
1598
|
+
}
|
|
1599
|
+
function isBroadMappedKey(type, environment, substitutions) {
|
|
1600
|
+
const unwrapped = unwrapTransparentType(type);
|
|
1601
|
+
if (unwrapped.type === "TSStringKeyword" || unwrapped.type === "TSNumberKeyword" || unwrapped.type === "TSSymbolKeyword") return true;
|
|
1602
|
+
if (unwrapped.type === "TSUnionType") return unwrapped.types.every((member) => isBroadMappedKey(member, environment, substitutions));
|
|
1603
|
+
if (unwrapped.type !== "TSTypeReference") return false;
|
|
1604
|
+
const name = typeReferenceName$1(unwrapped);
|
|
1605
|
+
if (name === null) return false;
|
|
1606
|
+
const substitution = substitutions.get(name);
|
|
1607
|
+
if (substitution !== void 0 && !isUnappliedReferenceTo(substitution, name)) return isBroadMappedKey(substitution, environment, substitutions);
|
|
1608
|
+
return name === "PropertyKey" && isBuiltIn(name, environment);
|
|
1609
|
+
}
|
|
1610
|
+
function classifyAliasBroadTarget(type, environment, substitutions, resolvingAliases) {
|
|
1611
|
+
const unwrapped = unwrapTransparentType(type);
|
|
1612
|
+
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
|
|
1613
|
+
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
|
|
1614
|
+
if (unwrapped.type === "TSTypeLiteral") return unwrapped.members.some((member) => member.type === "TSIndexSignature") ? { kind: "open dictionary" } : null;
|
|
1615
|
+
if (unwrapped.type === "TSMappedType") return isBroadMappedKey(unwrapped.constraint, environment, substitutions) ? { kind: "open dictionary" } : null;
|
|
1616
|
+
if (unwrapped.type !== "TSTypeReference") return null;
|
|
1617
|
+
const name = typeReferenceName$1(unwrapped);
|
|
1618
|
+
if (name === null) return null;
|
|
1619
|
+
const substitution = substitutions.get(name);
|
|
1620
|
+
if (substitution !== void 0) return isUnappliedReferenceTo(substitution, name) ? null : classifyAliasBroadTarget(substitution, environment, substitutions, resolvingAliases);
|
|
1621
|
+
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
|
|
1622
|
+
const wrapped = unwrapped.typeArguments?.params[0];
|
|
1623
|
+
return wrapped === void 0 ? null : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases);
|
|
1624
|
+
}
|
|
1625
|
+
if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" };
|
|
1626
|
+
const alias = environment.aliases.get(name);
|
|
1627
|
+
if (alias === void 0 || resolvingAliases.has(name)) return null;
|
|
1628
|
+
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
|
1629
|
+
if (nextSubstitutions === null) return null;
|
|
1630
|
+
const nextResolving = new Set(resolvingAliases);
|
|
1631
|
+
nextResolving.add(name);
|
|
1632
|
+
return classifyAliasBroadTarget(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
|
|
1633
|
+
}
|
|
1634
|
+
function isKnownEvidenceExpression(expression) {
|
|
1635
|
+
let current = expression;
|
|
1636
|
+
while (current.type === "ParenthesizedExpression" || current.type === "TSAsExpression" || current.type === "TSTypeAssertion" || current.type === "TSNonNullExpression" || current.type === "TSSatisfiesExpression") current = current.expression;
|
|
1637
|
+
if (current.type === "ObjectExpression") return true;
|
|
1638
|
+
return current.type === "ArrayExpression" || current.type === "ArrowFunctionExpression" || current.type === "ClassExpression" || current.type === "FunctionExpression" || current.type === "NewExpression" || current.type === "Literal" || current.type === "TemplateLiteral" || current.type === "UnaryExpression";
|
|
1639
|
+
}
|
|
1640
|
+
//#endregion
|
|
1641
|
+
//#region src/rules/no-known-value-widening.ts
|
|
1642
|
+
function unwrapExpression(expression) {
|
|
1643
|
+
let current = expression;
|
|
1644
|
+
while (current.type === "ParenthesizedExpression" || current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSTypeAssertion" || current.type === "TSNonNullExpression") current = current.expression;
|
|
1645
|
+
return current;
|
|
1646
|
+
}
|
|
1647
|
+
function resolveVariable$2(sourceCode, identifier) {
|
|
1648
|
+
let scope = sourceCode.getScope(identifier);
|
|
1649
|
+
while (scope !== null) {
|
|
1650
|
+
const variable = scope.set.get(identifier.name);
|
|
1651
|
+
if (variable !== void 0) return variable;
|
|
1652
|
+
scope = scope.upper;
|
|
1653
|
+
}
|
|
1654
|
+
return null;
|
|
1655
|
+
}
|
|
1656
|
+
function variableDeclarator(variable) {
|
|
1657
|
+
if (variable.defs.length !== 1) return null;
|
|
1658
|
+
const [definition] = variable.defs;
|
|
1659
|
+
return definition?.type === "Variable" && definition.node.type === "VariableDeclarator" ? definition.node : null;
|
|
1660
|
+
}
|
|
1661
|
+
function isStableConstVariable(variable, declarator) {
|
|
1662
|
+
return declarator.parent.type === "VariableDeclaration" && declarator.parent.kind === "const" && variable.references.every((reference) => reference.init || !reference.isWrite());
|
|
1663
|
+
}
|
|
1664
|
+
function hasKnownEvidence(sourceCode, expression, visitedVariables = /* @__PURE__ */ new Set()) {
|
|
1665
|
+
if (isKnownEvidenceExpression(expression)) return true;
|
|
1666
|
+
const unwrapped = unwrapExpression(expression);
|
|
1667
|
+
if (unwrapped.type !== "Identifier") return false;
|
|
1668
|
+
const variable = resolveVariable$2(sourceCode, unwrapped);
|
|
1669
|
+
if (variable === null || visitedVariables.has(variable)) return false;
|
|
1670
|
+
const declarator = variableDeclarator(variable);
|
|
1671
|
+
if (declarator === null || declarator.init === null || !isStableConstVariable(variable, declarator)) return false;
|
|
1672
|
+
visitedVariables.add(variable);
|
|
1673
|
+
return hasKnownEvidence(sourceCode, declarator.init, visitedVariables);
|
|
1674
|
+
}
|
|
1675
|
+
function annotationTarget(annotation, environment) {
|
|
1676
|
+
return annotation === null || annotation === void 0 ? null : classifyWideningTarget(annotation.typeAnnotation, environment);
|
|
1677
|
+
}
|
|
1678
|
+
function enclosingFunction(node) {
|
|
1679
|
+
let current = node.parent;
|
|
1680
|
+
while (current !== null && current.type !== "Program") {
|
|
1681
|
+
if (current.type === "ArrowFunctionExpression" || current.type === "FunctionDeclaration" || current.type === "FunctionExpression") return current;
|
|
1682
|
+
current = current.parent;
|
|
1683
|
+
}
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
function sourceKeyName(sourceCode, key) {
|
|
1687
|
+
if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name;
|
|
1688
|
+
if (key.type === "Literal") return String(key.value);
|
|
1689
|
+
return sourceCode.getText(key);
|
|
1690
|
+
}
|
|
1691
|
+
function functionName(sourceCode, owner) {
|
|
1692
|
+
if (owner === null) return "anonymous function";
|
|
1693
|
+
if (owner.id !== null) return owner.id.name;
|
|
1694
|
+
const parent = owner.parent;
|
|
1695
|
+
if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") return parent.id.name;
|
|
1696
|
+
if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key);
|
|
1697
|
+
return "anonymous function";
|
|
1698
|
+
}
|
|
1699
|
+
function isEmptyObjectExpression(expression) {
|
|
1700
|
+
const unwrapped = unwrapExpression(expression);
|
|
1701
|
+
return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0;
|
|
1702
|
+
}
|
|
1703
|
+
function isDictionaryAccumulatorTarget(destination) {
|
|
1704
|
+
return destination.kind === "open dictionary" || destination.kind === "generic container";
|
|
1705
|
+
}
|
|
1706
|
+
function hasParentAssertion(node) {
|
|
1707
|
+
return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion";
|
|
1708
|
+
}
|
|
1709
|
+
/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */
|
|
1710
|
+
const noKnownValueWideningRule = defineRule({
|
|
1711
|
+
meta: {
|
|
1712
|
+
type: "problem",
|
|
1713
|
+
docs: { description: "Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence." },
|
|
1714
|
+
messages: { widening: "The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract." }
|
|
1715
|
+
},
|
|
1716
|
+
createOnce(context) {
|
|
1717
|
+
let environment = null;
|
|
1718
|
+
const reportFlow = (expression, destination, subject) => {
|
|
1719
|
+
if (destination === null) return;
|
|
1720
|
+
if (isDictionaryAccumulatorTarget(destination) && isEmptyObjectExpression(expression)) return;
|
|
1721
|
+
if (!hasKnownEvidence(context.sourceCode, expression)) return;
|
|
1722
|
+
context.report({
|
|
1723
|
+
node: expression,
|
|
1724
|
+
messageId: "widening",
|
|
1725
|
+
data: {
|
|
1726
|
+
subject,
|
|
1727
|
+
target: destination.kind
|
|
1728
|
+
}
|
|
1729
|
+
});
|
|
1730
|
+
};
|
|
1731
|
+
const targetFromAnnotation = (annotation) => environment === null ? null : annotationTarget(annotation, environment);
|
|
1732
|
+
return {
|
|
1733
|
+
Program(node) {
|
|
1734
|
+
environment = createTypeEnvironment(node);
|
|
1735
|
+
},
|
|
1736
|
+
VariableDeclarator(node) {
|
|
1737
|
+
if (node.init === null || node.id.type !== "Identifier") return;
|
|
1738
|
+
reportFlow(node.init, targetFromAnnotation(node.id.typeAnnotation), `binding \`${node.id.name}\``);
|
|
1739
|
+
},
|
|
1740
|
+
PropertyDefinition(node) {
|
|
1741
|
+
if (node.value === null) return;
|
|
1742
|
+
reportFlow(node.value, targetFromAnnotation(node.typeAnnotation), `property \`${sourceKeyName(context.sourceCode, node.key)}\``);
|
|
1743
|
+
},
|
|
1744
|
+
AccessorProperty(node) {
|
|
1745
|
+
if (node.value === null) return;
|
|
1746
|
+
reportFlow(node.value, targetFromAnnotation(node.typeAnnotation), `property \`${sourceKeyName(context.sourceCode, node.key)}\``);
|
|
1747
|
+
},
|
|
1748
|
+
AssignmentExpression(node) {
|
|
1749
|
+
if (node.operator !== "=" || node.left.type !== "Identifier") return;
|
|
1750
|
+
const variable = resolveVariable$2(context.sourceCode, node.left);
|
|
1751
|
+
if (variable === null) return;
|
|
1752
|
+
const declarator = variableDeclarator(variable);
|
|
1753
|
+
if (declarator === null || declarator.id.type !== "Identifier") return;
|
|
1754
|
+
reportFlow(node.right, targetFromAnnotation(declarator.id.typeAnnotation), `binding \`${declarator.id.name}\``);
|
|
1755
|
+
},
|
|
1756
|
+
ReturnStatement(node) {
|
|
1757
|
+
if (node.argument === null) return;
|
|
1758
|
+
const owner = enclosingFunction(node);
|
|
1759
|
+
reportFlow(node.argument, targetFromAnnotation(owner?.returnType), `return value of \`${functionName(context.sourceCode, owner)}\``);
|
|
1760
|
+
},
|
|
1761
|
+
ArrowFunctionExpression(node) {
|
|
1762
|
+
if (node.body.type === "BlockStatement") return;
|
|
1763
|
+
reportFlow(node.body, targetFromAnnotation(node.returnType), `return value of \`${functionName(context.sourceCode, node)}\``);
|
|
1764
|
+
},
|
|
1765
|
+
TSAsExpression(node) {
|
|
1766
|
+
if (environment === null || hasParentAssertion(node)) return;
|
|
1767
|
+
reportFlow(node.expression, classifyWideningTarget(node.typeAnnotation, environment), "assertion");
|
|
1768
|
+
},
|
|
1769
|
+
TSTypeAssertion(node) {
|
|
1770
|
+
if (environment === null || hasParentAssertion(node)) return;
|
|
1771
|
+
reportFlow(node.expression, classifyWideningTarget(node.typeAnnotation, environment), "assertion");
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
});
|
|
1776
|
+
//#endregion
|
|
1777
|
+
//#region src/rules/no-module-mocking.ts
|
|
1778
|
+
const moduleMockMethods = /* @__PURE__ */ new Set([
|
|
1779
|
+
"doMock",
|
|
1780
|
+
"mock",
|
|
1781
|
+
"unstable_mockModule"
|
|
1782
|
+
]);
|
|
1783
|
+
function resolveVariable$1(sourceCode, identifier) {
|
|
1784
|
+
let scope = sourceCode.getScope(identifier);
|
|
1785
|
+
while (scope !== null) {
|
|
1786
|
+
const variable = scope.set.get(identifier.name);
|
|
1787
|
+
if (variable !== void 0) return variable;
|
|
1788
|
+
scope = scope.upper;
|
|
1789
|
+
}
|
|
1790
|
+
return null;
|
|
1791
|
+
}
|
|
1792
|
+
function importedName(node) {
|
|
1793
|
+
if (node.type !== "ImportSpecifier") return null;
|
|
1794
|
+
return node.imported.type === "Identifier" ? node.imported.name : node.imported.value;
|
|
1795
|
+
}
|
|
1796
|
+
function isTestFrameworkObject(sourceCode, expression) {
|
|
1797
|
+
if (expression.type !== "Identifier") return false;
|
|
1798
|
+
if ((expression.name === "vi" || expression.name === "jest") && sourceCode.isGlobalReference(expression)) return true;
|
|
1799
|
+
const variable = resolveVariable$1(sourceCode, expression);
|
|
1800
|
+
if (variable === null || variable.defs.length === 0) return expression.name === "vi" || expression.name === "jest";
|
|
1801
|
+
return variable.defs.some((definition) => {
|
|
1802
|
+
if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") return false;
|
|
1803
|
+
const source = definition.parent.source.value;
|
|
1804
|
+
const name = importedName(definition.node);
|
|
1805
|
+
return source === "vitest" && name === "vi" || source === "@jest/globals" && name === "jest";
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
function moduleMockCall(sourceCode, callee) {
|
|
1809
|
+
if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false;
|
|
1810
|
+
if (!isTestFrameworkObject(sourceCode, callee.object)) return false;
|
|
1811
|
+
const property = callee.property;
|
|
1812
|
+
const method = callee.computed ? property.type === "Literal" && (property.value === "doMock" || property.value === "mock" || property.value === "unstable_mockModule") ? property.value : null : property.type === "Identifier" ? property.name : null;
|
|
1813
|
+
return method !== null && moduleMockMethods.has(method);
|
|
1814
|
+
}
|
|
1815
|
+
/** Ban test framework module mocking in favor of real dependency seams. */
|
|
1816
|
+
const noModuleMockingRule = defineRule({
|
|
1817
|
+
meta: {
|
|
1818
|
+
type: "problem",
|
|
1819
|
+
docs: { description: "Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces." },
|
|
1820
|
+
messages: { moduleMock: "Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation." }
|
|
1821
|
+
},
|
|
1822
|
+
createOnce(context) {
|
|
1823
|
+
return { CallExpression(node) {
|
|
1824
|
+
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
|
|
1825
|
+
if (moduleMockCall(context.sourceCode, node.callee)) context.report({
|
|
1826
|
+
node,
|
|
1827
|
+
messageId: "moduleMock"
|
|
1828
|
+
});
|
|
1829
|
+
} };
|
|
1830
|
+
}
|
|
1831
|
+
});
|
|
1832
|
+
//#endregion
|
|
1833
|
+
//#region src/shared/lexical-type-parameters.ts
|
|
1834
|
+
function isNode(value) {
|
|
1835
|
+
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
|
1836
|
+
}
|
|
1837
|
+
function collectInferTypeParameterNames(node, visitorKeys, names) {
|
|
1838
|
+
if (node.type === "TSInferType") names.add(node.typeParameter.name.name);
|
|
1839
|
+
const record = node;
|
|
1840
|
+
for (const key of visitorKeys[node.type] ?? []) {
|
|
1841
|
+
const value = record[key];
|
|
1842
|
+
if (isNode(value)) {
|
|
1843
|
+
collectInferTypeParameterNames(value, visitorKeys, names);
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1846
|
+
if (!Array.isArray(value)) continue;
|
|
1847
|
+
for (const child of value) if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
/** Collect type binders that are in scope at a node and can shadow module aliases. */
|
|
1851
|
+
function lexicalTypeParameterNames(node, visitorKeys) {
|
|
1852
|
+
const names = /* @__PURE__ */ new Set();
|
|
1853
|
+
let descendant = node;
|
|
1854
|
+
let current = node;
|
|
1855
|
+
while (current !== null && current.type !== "Program") {
|
|
1856
|
+
if ("typeParameters" in current) for (const parameter of current.typeParameters?.params ?? []) names.add(parameter.name.name);
|
|
1857
|
+
if (current.type === "TSMappedType" && (descendant === current.nameType || descendant === current.typeAnnotation)) names.add(current.key.name);
|
|
1858
|
+
if (current.type === "TSConditionalType" && descendant === current.trueType) collectInferTypeParameterNames(current.extendsType, visitorKeys, names);
|
|
1859
|
+
descendant = current;
|
|
1860
|
+
current = current.parent;
|
|
1861
|
+
}
|
|
1862
|
+
return names;
|
|
1863
|
+
}
|
|
1864
|
+
//#endregion
|
|
1865
|
+
//#region src/rules/no-object-parameters.ts
|
|
1866
|
+
function parameterAnnotation(parameter) {
|
|
1867
|
+
if (parameter.type === "TSParameterProperty") return parameterAnnotation(parameter.parameter);
|
|
1868
|
+
if (parameter.type === "RestElement") return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument);
|
|
1869
|
+
if (parameter.type === "AssignmentPattern") return parameter.typeAnnotation ?? parameter.left.typeAnnotation;
|
|
1870
|
+
return parameter.typeAnnotation;
|
|
1871
|
+
}
|
|
1872
|
+
function parameterName(parameter, sourceCode) {
|
|
1873
|
+
return parameter.type === "Identifier" ? parameter.name : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, "");
|
|
1874
|
+
}
|
|
1875
|
+
/** Ban the broad object type on function inputs, including local aliases to object. */
|
|
1876
|
+
const noObjectParametersRule = defineRule({
|
|
1877
|
+
meta: {
|
|
1878
|
+
type: "problem",
|
|
1879
|
+
docs: { description: "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary." },
|
|
1880
|
+
messages: { objectParameter: "Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function." }
|
|
1881
|
+
},
|
|
1882
|
+
createOnce(context) {
|
|
1883
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1884
|
+
const resolvesToObject = (type, shadowedAliases, visited = /* @__PURE__ */ new Set()) => {
|
|
1885
|
+
if (type.type === "TSObjectKeyword") return true;
|
|
1886
|
+
if (type.type === "TSParenthesizedType") return resolvesToObject(type.typeAnnotation, shadowedAliases, visited);
|
|
1887
|
+
if (type.type === "TSUnionType") return type.types.some((member) => resolvesToObject(member, shadowedAliases, visited));
|
|
1888
|
+
if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier" || type.typeArguments !== null && type.typeArguments !== void 0 && type.typeArguments.params.length > 0 || visited.has(type.typeName.name) || shadowedAliases.has(type.typeName.name)) return false;
|
|
1889
|
+
const alias = aliases.get(type.typeName.name);
|
|
1890
|
+
if (alias === void 0) return false;
|
|
1891
|
+
const nextVisited = new Set(visited);
|
|
1892
|
+
nextVisited.add(type.typeName.name);
|
|
1893
|
+
return resolvesToObject(alias, shadowedAliases, nextVisited);
|
|
1894
|
+
};
|
|
1895
|
+
const checkParameters = (node) => {
|
|
1896
|
+
const shadowedAliases = lexicalTypeParameterNames(node, context.sourceCode.visitorKeys);
|
|
1897
|
+
for (const parameter of node.params) {
|
|
1898
|
+
const annotation = parameterAnnotation(parameter);
|
|
1899
|
+
if (annotation === null || annotation === void 0) continue;
|
|
1900
|
+
if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue;
|
|
1901
|
+
context.report({
|
|
1902
|
+
node: annotation.typeAnnotation,
|
|
1903
|
+
messageId: "objectParameter",
|
|
1904
|
+
data: { parameter: parameterName(parameter, context.sourceCode) }
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
};
|
|
1908
|
+
return {
|
|
1909
|
+
Program(node) {
|
|
1910
|
+
aliases.clear();
|
|
1911
|
+
for (const statement of node.body) {
|
|
1912
|
+
const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
|
1913
|
+
if (declaration?.type === "TSTypeAliasDeclaration" && (declaration.typeParameters === null || declaration.typeParameters === void 0)) aliases.set(declaration.id.name, declaration.typeAnnotation);
|
|
1914
|
+
}
|
|
1915
|
+
},
|
|
1916
|
+
ArrowFunctionExpression: checkParameters,
|
|
1917
|
+
FunctionDeclaration: checkParameters,
|
|
1918
|
+
FunctionExpression: checkParameters,
|
|
1919
|
+
TSCallSignatureDeclaration: checkParameters,
|
|
1920
|
+
TSConstructSignatureDeclaration: checkParameters,
|
|
1921
|
+
TSConstructorType: checkParameters,
|
|
1922
|
+
TSDeclareFunction: checkParameters,
|
|
1923
|
+
TSEmptyBodyFunctionExpression: checkParameters,
|
|
1924
|
+
TSFunctionType: checkParameters,
|
|
1925
|
+
TSMethodSignature: checkParameters
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
});
|
|
1929
|
+
//#endregion
|
|
1930
|
+
//#region src/shared/reflect-method.ts
|
|
1931
|
+
function resolveVariable(sourceCode, identifier) {
|
|
1932
|
+
let scope = sourceCode.getScope(identifier);
|
|
1933
|
+
while (scope !== null) {
|
|
1934
|
+
const variable = scope.set.get(identifier.name);
|
|
1935
|
+
if (variable !== void 0) return variable;
|
|
1936
|
+
scope = scope.upper;
|
|
1937
|
+
}
|
|
1938
|
+
return null;
|
|
1939
|
+
}
|
|
1940
|
+
function isGlobalReflect(sourceCode, expression) {
|
|
1941
|
+
if (expression.type !== "Identifier" || expression.name !== "Reflect") return false;
|
|
1942
|
+
if (sourceCode.isGlobalReference(expression)) return true;
|
|
1943
|
+
const variable = resolveVariable(sourceCode, expression);
|
|
1944
|
+
return variable === null || variable.defs.length === 0;
|
|
1945
|
+
}
|
|
1946
|
+
/** Reports whether a call target names one method on the global Reflect object. */
|
|
1947
|
+
function isGlobalReflectMethodCall(sourceCode, callee, methodName) {
|
|
1948
|
+
if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false;
|
|
1949
|
+
if (!isGlobalReflect(sourceCode, callee.object)) return false;
|
|
1950
|
+
const property = callee.property;
|
|
1951
|
+
return callee.computed ? property.type === "Literal" && property.value === methodName : property.type === "Identifier" && property.name === methodName;
|
|
1952
|
+
}
|
|
1953
|
+
//#endregion
|
|
1954
|
+
//#region src/rules/no-reflect-apply.ts
|
|
1955
|
+
/** Ban Reflect.apply, which bypasses ordinary typed function calls. */
|
|
1956
|
+
const noReflectApplyRule = defineRule({
|
|
1957
|
+
meta: {
|
|
1958
|
+
type: "problem",
|
|
1959
|
+
docs: { description: "Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface." },
|
|
1960
|
+
messages: { reflectApply: "Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface." }
|
|
1961
|
+
},
|
|
1962
|
+
createOnce(context) {
|
|
1963
|
+
return { CallExpression(node) {
|
|
1964
|
+
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
|
|
1965
|
+
if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) context.report({
|
|
1966
|
+
node,
|
|
1967
|
+
messageId: "reflectApply"
|
|
1968
|
+
});
|
|
1969
|
+
} };
|
|
1970
|
+
}
|
|
1971
|
+
});
|
|
1972
|
+
//#endregion
|
|
1973
|
+
//#region src/rules/no-reflect-get.ts
|
|
1974
|
+
/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */
|
|
1975
|
+
const noReflectGetRule = defineRule({
|
|
1976
|
+
meta: {
|
|
1977
|
+
type: "problem",
|
|
1978
|
+
docs: { description: "Disallow Reflect.get; use typed property access or parse dynamic input into a domain type." },
|
|
1979
|
+
messages: { reflectGet: "Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it." }
|
|
1980
|
+
},
|
|
1981
|
+
createOnce(context) {
|
|
1982
|
+
return { CallExpression(node) {
|
|
1983
|
+
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
|
|
1984
|
+
if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) context.report({
|
|
1985
|
+
node,
|
|
1986
|
+
messageId: "reflectGet"
|
|
1987
|
+
});
|
|
1988
|
+
} };
|
|
1989
|
+
}
|
|
1990
|
+
});
|
|
1991
|
+
//#endregion
|
|
1992
|
+
//#region src/rules/no-runtime-typeof.ts
|
|
1993
|
+
function isRuntimeFunction(node) {
|
|
1994
|
+
return node.type === "ArrowFunctionExpression" || node.type === "FunctionDeclaration" || node.type === "FunctionExpression";
|
|
1995
|
+
}
|
|
1996
|
+
function isInsideTypeGuard(node) {
|
|
1997
|
+
let current = node.parent;
|
|
1998
|
+
while (current !== null && current.type !== "Program") {
|
|
1999
|
+
if (isRuntimeFunction(current)) return current.returnType?.typeAnnotation.type === "TSTypePredicate";
|
|
2000
|
+
current = current.parent;
|
|
2001
|
+
}
|
|
2002
|
+
return false;
|
|
2003
|
+
}
|
|
2004
|
+
/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */
|
|
2005
|
+
const noRuntimeTypeofRule = defineRule({
|
|
2006
|
+
meta: {
|
|
2007
|
+
type: "problem",
|
|
2008
|
+
docs: { description: "Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary." },
|
|
2009
|
+
messages: { runtimeTypeof: "A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value." },
|
|
2010
|
+
schema: [{
|
|
2011
|
+
type: "object",
|
|
2012
|
+
properties: { allowInTypeGuards: { type: "boolean" } },
|
|
2013
|
+
additionalProperties: false
|
|
2014
|
+
}],
|
|
2015
|
+
defaultOptions: [{ allowInTypeGuards: false }]
|
|
2016
|
+
},
|
|
2017
|
+
createOnce(context) {
|
|
2018
|
+
return { UnaryExpression(node) {
|
|
2019
|
+
const option = context.options?.[0];
|
|
2020
|
+
const allowInTypeGuards = typeof option === "object" && option !== null && !Array.isArray(option) && option.allowInTypeGuards === true;
|
|
2021
|
+
if (node.operator === "typeof" && (!allowInTypeGuards || !isInsideTypeGuard(node))) context.report({
|
|
2022
|
+
node,
|
|
2023
|
+
messageId: "runtimeTypeof"
|
|
2024
|
+
});
|
|
2025
|
+
} };
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
//#endregion
|
|
2029
|
+
//#region src/rules/no-test-imports.ts
|
|
2030
|
+
/** Keep test dependencies out of a package's production source tree. */
|
|
2031
|
+
const noTestImportsRule = defineRule({
|
|
2032
|
+
meta: {
|
|
2033
|
+
type: "problem",
|
|
2034
|
+
docs: { description: "Disallow production source imports of tests, test helpers, and fixtures." },
|
|
2035
|
+
schema: [],
|
|
2036
|
+
messages: { testImport: "Production source cannot import test code or resources. Move shared production code into src/." }
|
|
2037
|
+
},
|
|
2038
|
+
create(context) {
|
|
2039
|
+
const filename = physicalPath(context.filename);
|
|
2040
|
+
const owner = packageOwner(filename);
|
|
2041
|
+
if (!owner || !packagePath(owner, filename).startsWith("src/")) return {};
|
|
2042
|
+
const resolution = importResolution();
|
|
2043
|
+
return importSources(() => context.sourceCode, (node, specifier) => {
|
|
2044
|
+
const target = resolution.target(filename, specifier);
|
|
2045
|
+
if (!target) return;
|
|
2046
|
+
const destination = packageOwner(target);
|
|
2047
|
+
if (destination && isTestPath(packagePath(destination, target))) context.report({
|
|
2048
|
+
node,
|
|
2049
|
+
messageId: "testImport"
|
|
2050
|
+
});
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
});
|
|
2054
|
+
//#endregion
|
|
2055
|
+
//#region src/rules/no-type-assertions.ts
|
|
2056
|
+
function isConstAssertion(node) {
|
|
2057
|
+
return node.typeAnnotation.type === "TSTypeReference" && node.typeAnnotation.typeName.type === "Identifier" && node.typeAnnotation.typeName.name === "const";
|
|
2058
|
+
}
|
|
2059
|
+
function isNestedAssertion(node) {
|
|
2060
|
+
return node.parent.type === "TSAsExpression" || node.parent.type === "TSTypeAssertion";
|
|
2061
|
+
}
|
|
2062
|
+
/** Reject non-const type assertions instead of allowing a comment-based escape hatch. */
|
|
2063
|
+
const noTypeAssertionsRule = defineRule({
|
|
2064
|
+
meta: {
|
|
2065
|
+
type: "problem",
|
|
2066
|
+
docs: { description: "Reject non-const type assertions; decode unknown input or preserve the type inferred by a typed API." },
|
|
2067
|
+
messages: { typeAssertion: "Type assertions are forbidden. Decode unknown input with Schema, or preserve the type inferred by Effect SQL, Drizzle, or another typed API." }
|
|
2068
|
+
},
|
|
2069
|
+
createOnce(context) {
|
|
2070
|
+
const checkAssertion = (node) => {
|
|
2071
|
+
if (isConstAssertion(node) || isNestedAssertion(node)) return;
|
|
2072
|
+
context.report({
|
|
2073
|
+
node,
|
|
2074
|
+
messageId: "typeAssertion"
|
|
2075
|
+
});
|
|
2076
|
+
};
|
|
2077
|
+
return {
|
|
2078
|
+
TSAsExpression: checkAssertion,
|
|
2079
|
+
TSTypeAssertion: checkAssertion
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
});
|
|
2083
|
+
//#endregion
|
|
2084
|
+
//#region src/rules/no-unknown-returns.ts
|
|
2085
|
+
function referencedAliasName$1(type) {
|
|
2086
|
+
if (type.type === "TSParenthesizedType") return referencedAliasName$1(type.typeAnnotation);
|
|
2087
|
+
if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null;
|
|
2088
|
+
return type.typeArguments === null || type.typeArguments === void 0 || type.typeArguments.params.length === 0 ? type.typeName.name : null;
|
|
2089
|
+
}
|
|
2090
|
+
function isConditionalTypeConstraint(node) {
|
|
2091
|
+
let current = node.parent;
|
|
2092
|
+
while (current !== null && current.type !== "Program") {
|
|
2093
|
+
if (current.type === "TSConditionalType") return current.extendsType === node;
|
|
2094
|
+
current = current.parent;
|
|
2095
|
+
}
|
|
2096
|
+
return false;
|
|
2097
|
+
}
|
|
2098
|
+
/** Ban function contracts that return unknown instead of a parsed domain type. */
|
|
2099
|
+
const noUnknownReturnsRule = defineRule({
|
|
2100
|
+
meta: {
|
|
2101
|
+
type: "problem",
|
|
2102
|
+
docs: { description: "Disallow functions whose explicit return contract is unknown or Promise<unknown>." },
|
|
2103
|
+
messages: { unknownReturn: "This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type." }
|
|
2104
|
+
},
|
|
2105
|
+
createOnce(context) {
|
|
2106
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
2107
|
+
const resolvesToUnknown = (type, shadowedAliases, visited = /* @__PURE__ */ new Set()) => {
|
|
2108
|
+
if (type.type === "TSUnknownKeyword") return true;
|
|
2109
|
+
if (type.type === "TSParenthesizedType") return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited);
|
|
2110
|
+
if (type.type === "TSUnionType") return type.types.some((member) => resolvesToUnknown(member, shadowedAliases, visited));
|
|
2111
|
+
if (type.type === "TSTypeReference" && type.typeName.type === "Identifier" && (type.typeName.name === "Promise" || type.typeName.name === "PromiseLike")) {
|
|
2112
|
+
const value = type.typeArguments?.params[0];
|
|
2113
|
+
return value !== void 0 && resolvesToUnknown(value, shadowedAliases, visited);
|
|
2114
|
+
}
|
|
2115
|
+
const name = referencedAliasName$1(type);
|
|
2116
|
+
if (name === null || visited.has(name) || shadowedAliases.has(name)) return false;
|
|
2117
|
+
const alias = aliases.get(name);
|
|
2118
|
+
if (alias === void 0 || alias.typeParameters !== null && alias.typeParameters !== void 0) return false;
|
|
2119
|
+
const nextVisited = new Set(visited);
|
|
2120
|
+
nextVisited.add(name);
|
|
2121
|
+
return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited);
|
|
2122
|
+
};
|
|
2123
|
+
const checkReturnType = (node) => {
|
|
2124
|
+
if (isConditionalTypeConstraint(node)) return;
|
|
2125
|
+
const annotation = node.returnType;
|
|
2126
|
+
if (annotation === null || annotation === void 0) return;
|
|
2127
|
+
if (!resolvesToUnknown(annotation.typeAnnotation, lexicalTypeParameterNames(node, context.sourceCode.visitorKeys))) return;
|
|
2128
|
+
context.report({
|
|
2129
|
+
node: annotation.typeAnnotation,
|
|
2130
|
+
messageId: "unknownReturn"
|
|
2131
|
+
});
|
|
2132
|
+
};
|
|
2133
|
+
return {
|
|
2134
|
+
Program(node) {
|
|
2135
|
+
aliases.clear();
|
|
2136
|
+
for (const statement of node.body) {
|
|
2137
|
+
const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
|
2138
|
+
if (declaration?.type === "TSTypeAliasDeclaration") aliases.set(declaration.id.name, declaration);
|
|
2139
|
+
}
|
|
2140
|
+
},
|
|
2141
|
+
ArrowFunctionExpression: checkReturnType,
|
|
2142
|
+
FunctionDeclaration: checkReturnType,
|
|
2143
|
+
FunctionExpression: checkReturnType,
|
|
2144
|
+
TSCallSignatureDeclaration: checkReturnType,
|
|
2145
|
+
TSConstructSignatureDeclaration: checkReturnType,
|
|
2146
|
+
TSConstructorType: checkReturnType,
|
|
2147
|
+
TSDeclareFunction: checkReturnType,
|
|
2148
|
+
TSEmptyBodyFunctionExpression: checkReturnType,
|
|
2149
|
+
TSFunctionType: checkReturnType,
|
|
2150
|
+
TSMethodSignature: checkReturnType
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
});
|
|
2154
|
+
//#endregion
|
|
2155
|
+
//#region src/rules/no-unknown-type-aliases.ts
|
|
2156
|
+
function referencedAliasName(type) {
|
|
2157
|
+
if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation);
|
|
2158
|
+
if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null;
|
|
2159
|
+
return type.typeArguments === null || type.typeArguments === void 0 || type.typeArguments.params.length === 0 ? type.typeName.name : null;
|
|
2160
|
+
}
|
|
2161
|
+
/** Ban named aliases that merely conceal TypeScript's unknown top type. */
|
|
2162
|
+
const noUnknownTypeAliasesRule = defineRule({
|
|
2163
|
+
meta: {
|
|
2164
|
+
type: "problem",
|
|
2165
|
+
docs: { description: "Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary." },
|
|
2166
|
+
messages: { unknownAlias: "Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type." }
|
|
2167
|
+
},
|
|
2168
|
+
createOnce(context) {
|
|
2169
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
2170
|
+
const resolvesToUnknown = (type, visited = /* @__PURE__ */ new Set()) => {
|
|
2171
|
+
if (type.type === "TSUnknownKeyword") return true;
|
|
2172
|
+
if (type.type === "TSParenthesizedType") return resolvesToUnknown(type.typeAnnotation, visited);
|
|
2173
|
+
const name = referencedAliasName(type);
|
|
2174
|
+
if (name === null || visited.has(name)) return false;
|
|
2175
|
+
const alias = aliases.get(name);
|
|
2176
|
+
if (alias === void 0 || alias.typeParameters !== null && alias.typeParameters !== void 0) return false;
|
|
2177
|
+
const nextVisited = new Set(visited);
|
|
2178
|
+
nextVisited.add(name);
|
|
2179
|
+
return resolvesToUnknown(alias.typeAnnotation, nextVisited);
|
|
2180
|
+
};
|
|
2181
|
+
return { Program(node) {
|
|
2182
|
+
aliases.clear();
|
|
2183
|
+
for (const statement of node.body) {
|
|
2184
|
+
const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
|
2185
|
+
if (declaration?.type === "TSTypeAliasDeclaration") aliases.set(declaration.id.name, declaration);
|
|
2186
|
+
}
|
|
2187
|
+
for (const alias of aliases.values()) {
|
|
2188
|
+
if (!resolvesToUnknown(alias.typeAnnotation, /* @__PURE__ */ new Set([alias.id.name]))) continue;
|
|
2189
|
+
context.report({
|
|
2190
|
+
node: alias.id,
|
|
2191
|
+
messageId: "unknownAlias",
|
|
2192
|
+
data: { alias: alias.id.name }
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
} };
|
|
2196
|
+
}
|
|
2197
|
+
});
|
|
2198
|
+
//#endregion
|
|
2199
|
+
//#region src/rules/no-unsafe-dictionary-type.ts
|
|
2200
|
+
const typeNodeKinds = /* @__PURE__ */ new Set([
|
|
2201
|
+
"JSDocNonNullableType",
|
|
2202
|
+
"JSDocNullableType",
|
|
2203
|
+
"JSDocUnknownType",
|
|
2204
|
+
"TSAnyKeyword",
|
|
2205
|
+
"TSArrayType",
|
|
2206
|
+
"TSBigIntKeyword",
|
|
2207
|
+
"TSBooleanKeyword",
|
|
2208
|
+
"TSConditionalType",
|
|
2209
|
+
"TSConstructorType",
|
|
2210
|
+
"TSFunctionType",
|
|
2211
|
+
"TSImportType",
|
|
2212
|
+
"TSIndexedAccessType",
|
|
2213
|
+
"TSInferType",
|
|
2214
|
+
"TSIntersectionType",
|
|
2215
|
+
"TSIntrinsicKeyword",
|
|
2216
|
+
"TSLiteralType",
|
|
2217
|
+
"TSMappedType",
|
|
2218
|
+
"TSNamedTupleMember",
|
|
2219
|
+
"TSNeverKeyword",
|
|
2220
|
+
"TSNullKeyword",
|
|
2221
|
+
"TSNumberKeyword",
|
|
2222
|
+
"TSObjectKeyword",
|
|
2223
|
+
"TSParenthesizedType",
|
|
2224
|
+
"TSStringKeyword",
|
|
2225
|
+
"TSSymbolKeyword",
|
|
2226
|
+
"TSTemplateLiteralType",
|
|
2227
|
+
"TSThisType",
|
|
2228
|
+
"TSTupleType",
|
|
2229
|
+
"TSTypeLiteral",
|
|
2230
|
+
"TSTypeOperator",
|
|
2231
|
+
"TSTypePredicate",
|
|
2232
|
+
"TSTypeQuery",
|
|
2233
|
+
"TSTypeReference",
|
|
2234
|
+
"TSUndefinedKeyword",
|
|
2235
|
+
"TSUnionType",
|
|
2236
|
+
"TSUnknownKeyword",
|
|
2237
|
+
"TSVoidKeyword"
|
|
2238
|
+
]);
|
|
2239
|
+
function isTypeNode(node) {
|
|
2240
|
+
return typeNodeKinds.has(node.type);
|
|
2241
|
+
}
|
|
2242
|
+
function typeReferenceName(type) {
|
|
2243
|
+
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
|
2244
|
+
}
|
|
2245
|
+
function isInsideTypeAliasDeclaration(node) {
|
|
2246
|
+
let current = node.parent;
|
|
2247
|
+
while (current !== null && current.type !== "Program") {
|
|
2248
|
+
if (current.type === "TSTypeAliasDeclaration") return true;
|
|
2249
|
+
current = current.parent;
|
|
2250
|
+
}
|
|
2251
|
+
return false;
|
|
2252
|
+
}
|
|
2253
|
+
function isGenericConstraint(node) {
|
|
2254
|
+
let current = node.parent;
|
|
2255
|
+
while (current !== null && current.type !== "Program") {
|
|
2256
|
+
if (current.type === "TSTypeParameter") return current.constraint === node;
|
|
2257
|
+
current = current.parent;
|
|
2258
|
+
}
|
|
2259
|
+
return false;
|
|
2260
|
+
}
|
|
2261
|
+
function isPlainAliasConsumerUse(node, environment) {
|
|
2262
|
+
if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false;
|
|
2263
|
+
const name = typeReferenceName(node);
|
|
2264
|
+
return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node);
|
|
2265
|
+
}
|
|
2266
|
+
function shouldReportType(node, environment) {
|
|
2267
|
+
if (isGenericConstraint(node)) return false;
|
|
2268
|
+
if (isPlainAliasConsumerUse(node, environment)) return false;
|
|
2269
|
+
if (classifyUnsafeDictionary(node, environment) === null) return false;
|
|
2270
|
+
let current = node.parent;
|
|
2271
|
+
while (current !== null && current.type !== "Program") {
|
|
2272
|
+
if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) return false;
|
|
2273
|
+
current = current.parent;
|
|
2274
|
+
}
|
|
2275
|
+
return true;
|
|
2276
|
+
}
|
|
2277
|
+
/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */
|
|
2278
|
+
const noUnsafeDictionaryTypeRule = defineRule({
|
|
2279
|
+
meta: {
|
|
2280
|
+
type: "problem",
|
|
2281
|
+
docs: { description: "Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches." },
|
|
2282
|
+
messages: { unsafeDictionary: "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion." }
|
|
2283
|
+
},
|
|
2284
|
+
createOnce(context) {
|
|
2285
|
+
let environment = null;
|
|
2286
|
+
const report = (node, value) => {
|
|
2287
|
+
context.report({
|
|
2288
|
+
node,
|
|
2289
|
+
messageId: "unsafeDictionary",
|
|
2290
|
+
data: { value }
|
|
2291
|
+
});
|
|
2292
|
+
};
|
|
2293
|
+
const reportIfUnsafe = (node) => {
|
|
2294
|
+
if (environment === null || !shouldReportType(node, environment)) return;
|
|
2295
|
+
const unsafe = classifyUnsafeDictionary(node, environment);
|
|
2296
|
+
if (unsafe === null) return;
|
|
2297
|
+
report(node, unsafe.unsafeValue);
|
|
2298
|
+
};
|
|
2299
|
+
return {
|
|
2300
|
+
Program(node) {
|
|
2301
|
+
environment = createTypeEnvironment(node);
|
|
2302
|
+
},
|
|
2303
|
+
TSTypeReference: reportIfUnsafe,
|
|
2304
|
+
TSTypeLiteral: reportIfUnsafe,
|
|
2305
|
+
TSMappedType: reportIfUnsafe,
|
|
2306
|
+
TSIndexSignature(node) {
|
|
2307
|
+
if (environment === null || node.typeAnnotation === null || node.parent.type === "TSTypeLiteral") return;
|
|
2308
|
+
const unsafe = classifyUnsafeDictionaryValue(node.typeAnnotation.typeAnnotation, environment);
|
|
2309
|
+
if (unsafe !== null) report(node, unsafe.unsafeValue);
|
|
2310
|
+
}
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
});
|
|
2314
|
+
//#endregion
|
|
2315
|
+
//#region src/rules/require-public-jsdoc.ts
|
|
2316
|
+
const standardHeadings = [
|
|
2317
|
+
"**When to use**",
|
|
2318
|
+
"**Details**",
|
|
2319
|
+
"**Gotchas**"
|
|
2320
|
+
];
|
|
2321
|
+
const whenToUsePrefixes = [
|
|
2322
|
+
"Use to",
|
|
2323
|
+
"Use when",
|
|
2324
|
+
"Use as",
|
|
2325
|
+
"Use with"
|
|
2326
|
+
];
|
|
2327
|
+
const stableSemver = /^\d+\.\d+\.\d+$/u;
|
|
2328
|
+
const tagOrder = /* @__PURE__ */ new Map([
|
|
2329
|
+
["deprecated", 0],
|
|
2330
|
+
["see", 1],
|
|
2331
|
+
["category", 2],
|
|
2332
|
+
["since", 3]
|
|
2333
|
+
]);
|
|
2334
|
+
function diagnostic(code, message) {
|
|
2335
|
+
return {
|
|
2336
|
+
code,
|
|
2337
|
+
message
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
function normalizeJSDoc(comment) {
|
|
2341
|
+
const lines = comment.value.slice(1).split(/\r\n|\r|\n/u).map((line) => line.replace(/^\s*\* ?/u, "").trimEnd());
|
|
2342
|
+
let start = 0;
|
|
2343
|
+
let end = lines.length;
|
|
2344
|
+
if (lines[start]?.trim() === "") start++;
|
|
2345
|
+
if (end > start && lines[end - 1]?.trim() === "") end--;
|
|
2346
|
+
return lines.slice(start, end);
|
|
2347
|
+
}
|
|
2348
|
+
function parseTags(lines) {
|
|
2349
|
+
const tags = [];
|
|
2350
|
+
let current;
|
|
2351
|
+
let inFence = false;
|
|
2352
|
+
for (const [line, source] of lines.entries()) {
|
|
2353
|
+
const trimmed = source.trim();
|
|
2354
|
+
if (trimmed.startsWith("```")) {
|
|
2355
|
+
inFence = !inFence;
|
|
2356
|
+
current = void 0;
|
|
2357
|
+
continue;
|
|
2358
|
+
}
|
|
2359
|
+
if (inFence) continue;
|
|
2360
|
+
const match = /^@([A-Za-z][\w-]*)(?:\s+(.*))?$/u.exec(trimmed);
|
|
2361
|
+
if (match !== null) {
|
|
2362
|
+
current = {
|
|
2363
|
+
line,
|
|
2364
|
+
name: match[1] ?? "",
|
|
2365
|
+
value: match[2]?.trim() ?? ""
|
|
2366
|
+
};
|
|
2367
|
+
tags.push(current);
|
|
2368
|
+
continue;
|
|
2369
|
+
}
|
|
2370
|
+
if (current !== void 0 && trimmed !== "") current.value = current.value === "" ? trimmed : `${current.value}\n${trimmed}`;
|
|
2371
|
+
else if (trimmed === "") current = void 0;
|
|
2372
|
+
}
|
|
2373
|
+
return tags;
|
|
2374
|
+
}
|
|
2375
|
+
function isForbiddenMarkdownHeading(line) {
|
|
2376
|
+
return /^#{1,6}\s+/u.test(line.trim());
|
|
2377
|
+
}
|
|
2378
|
+
function isBoldOnlyLine(line) {
|
|
2379
|
+
return /^\*\*[^*]+\*\*\s*$/u.test(line);
|
|
2380
|
+
}
|
|
2381
|
+
function isNearMissHeading(line) {
|
|
2382
|
+
return /^\*\*(When to use|When To Use|Details|Gotchas).*\*\*/u.test(line) && !standardHeadings.includes(line);
|
|
2383
|
+
}
|
|
2384
|
+
function isHeadingLine(line) {
|
|
2385
|
+
if (line === void 0) return false;
|
|
2386
|
+
const trimmed = line.trim();
|
|
2387
|
+
return standardHeadings.includes(trimmed) || trimmed.startsWith("**Example**") || isNearMissHeading(trimmed) || isBoldOnlyLine(trimmed) || isForbiddenMarkdownHeading(trimmed);
|
|
2388
|
+
}
|
|
2389
|
+
function joinBody(lines) {
|
|
2390
|
+
let start = 0;
|
|
2391
|
+
let end = lines.length;
|
|
2392
|
+
while (start < end && lines[start]?.trim() === "") start++;
|
|
2393
|
+
while (end > start && lines[end - 1]?.trim() === "") end--;
|
|
2394
|
+
return lines.slice(start, end).join("\n");
|
|
2395
|
+
}
|
|
2396
|
+
function validateSection(lines, headingIndex) {
|
|
2397
|
+
const diagnostics = [];
|
|
2398
|
+
const heading = lines[headingIndex]?.trim() ?? "section";
|
|
2399
|
+
if (lines[headingIndex + 1]?.trim() !== "") diagnostics.push(diagnostic("invalid-spacing", `${heading} must be followed by exactly one blank line`));
|
|
2400
|
+
let index = headingIndex + 2;
|
|
2401
|
+
const bodyStart = index;
|
|
2402
|
+
let inFence = false;
|
|
2403
|
+
while (index < lines.length) {
|
|
2404
|
+
const trimmed = lines[index]?.trim() ?? "";
|
|
2405
|
+
if (trimmed.startsWith("```")) {
|
|
2406
|
+
if (/^```ts(?:\s.*)?$/u.test(trimmed)) diagnostics.push(diagnostic("loose-ts-fence", "TypeScript examples must use **Example** (Title) sections"));
|
|
2407
|
+
inFence = !inFence;
|
|
2408
|
+
}
|
|
2409
|
+
if (!inFence && trimmed === "" && isHeadingLine(lines[index + 1])) break;
|
|
2410
|
+
if (!inFence && isForbiddenMarkdownHeading(trimmed)) diagnostics.push(diagnostic("invalid-heading", "Markdown headings are not allowed in JSDoc descriptions"));
|
|
2411
|
+
if (!inFence && isBoldOnlyLine(trimmed) && !standardHeadings.includes(trimmed) && !trimmed.startsWith("**Example**")) diagnostics.push(diagnostic("unknown-heading", `Unknown JSDoc section heading: ${trimmed}`));
|
|
2412
|
+
index++;
|
|
2413
|
+
}
|
|
2414
|
+
const bodyLines = lines.slice(bodyStart, index);
|
|
2415
|
+
if (joinBody(bodyLines).trim() === "") diagnostics.push(diagnostic("empty-section", `${heading} must have a non-empty body`));
|
|
2416
|
+
if (bodyLines.at(-1)?.trim() === "") diagnostics.push(diagnostic("invalid-spacing", "Section bodies must not end with extra blank lines"));
|
|
2417
|
+
return {
|
|
2418
|
+
body: joinBody(bodyLines),
|
|
2419
|
+
diagnostics,
|
|
2420
|
+
nextIndex: index
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
function validateExample(lines, headingIndex) {
|
|
2424
|
+
const diagnostics = [];
|
|
2425
|
+
const heading = lines[headingIndex]?.trim() ?? "";
|
|
2426
|
+
const match = /^\*\*Example\*\* \((.+)\)$/u.exec(heading);
|
|
2427
|
+
if (match === null || match[1]?.trim() === "") diagnostics.push(diagnostic("malformed-example", "TypeScript examples must use **Example** (Title)"));
|
|
2428
|
+
if (lines[headingIndex + 1]?.trim() !== "") diagnostics.push(diagnostic("invalid-spacing", "Example headings must be followed by exactly one blank line"));
|
|
2429
|
+
let index = headingIndex + 2;
|
|
2430
|
+
const bodyStart = index;
|
|
2431
|
+
let fenceIndex = -1;
|
|
2432
|
+
while (index < lines.length) {
|
|
2433
|
+
const trimmed = lines[index]?.trim() ?? "";
|
|
2434
|
+
if (/^```ts(?:\s.*)?$/u.test(trimmed)) {
|
|
2435
|
+
fenceIndex = index;
|
|
2436
|
+
break;
|
|
2437
|
+
}
|
|
2438
|
+
if (trimmed.startsWith("```")) diagnostics.push(diagnostic("malformed-example", "Examples may only contain one TypeScript code fence"));
|
|
2439
|
+
if (trimmed === "" && isHeadingLine(lines[index + 1]) || trimmed.startsWith("@")) break;
|
|
2440
|
+
index++;
|
|
2441
|
+
}
|
|
2442
|
+
if (fenceIndex === -1) {
|
|
2443
|
+
diagnostics.push(diagnostic("malformed-example", "Examples must include a non-empty ```ts fence"));
|
|
2444
|
+
return {
|
|
2445
|
+
diagnostics,
|
|
2446
|
+
nextIndex: index
|
|
2447
|
+
};
|
|
2448
|
+
}
|
|
2449
|
+
const bodyLines = lines.slice(bodyStart, fenceIndex);
|
|
2450
|
+
if (joinBody(bodyLines).trim() !== "" && bodyLines.at(-1)?.trim() !== "") diagnostics.push(diagnostic("invalid-spacing", "Example prose must be separated from code by exactly one blank line"));
|
|
2451
|
+
index = fenceIndex + 1;
|
|
2452
|
+
const codeStart = index;
|
|
2453
|
+
while (index < lines.length && lines[index]?.trim() !== "```") {
|
|
2454
|
+
if (/^```ts(?:\s.*)?$/u.test(lines[index]?.trim() ?? "")) diagnostics.push(diagnostic("malformed-example", "Examples must contain exactly one TypeScript code fence"));
|
|
2455
|
+
index++;
|
|
2456
|
+
}
|
|
2457
|
+
const codeLines = lines.slice(codeStart, index);
|
|
2458
|
+
if (index >= lines.length) diagnostics.push(diagnostic("malformed-example", "Examples must close the TypeScript code fence"));
|
|
2459
|
+
if (joinBody(codeLines).trim() === "") diagnostics.push(diagnostic("malformed-example", "Examples must include non-empty TypeScript code"));
|
|
2460
|
+
index++;
|
|
2461
|
+
if (index < lines.length && lines[index]?.trim() !== "" && !lines[index]?.trim().startsWith("@")) diagnostics.push(diagnostic("invalid-spacing", "Examples must be separated from following content by exactly one blank line"));
|
|
2462
|
+
return {
|
|
2463
|
+
diagnostics,
|
|
2464
|
+
nextIndex: index,
|
|
2465
|
+
...match?.[1] === void 0 ? {} : { title: match[1].trim() }
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
function validateDescription(lines) {
|
|
2469
|
+
const diagnostics = [];
|
|
2470
|
+
const seenSections = /* @__PURE__ */ new Set();
|
|
2471
|
+
const exampleTitles = /* @__PURE__ */ new Set();
|
|
2472
|
+
let index = 0;
|
|
2473
|
+
let currentSectionOrder = -1;
|
|
2474
|
+
let examplesStarted = false;
|
|
2475
|
+
while (index < lines.length && lines[index]?.trim() !== "" && !isHeadingLine(lines[index])) {
|
|
2476
|
+
if (isForbiddenMarkdownHeading(lines[index] ?? "")) diagnostics.push(diagnostic("invalid-heading", "Markdown headings are not allowed in JSDoc descriptions"));
|
|
2477
|
+
index++;
|
|
2478
|
+
}
|
|
2479
|
+
const shortLines = lines.slice(0, index);
|
|
2480
|
+
if (shortLines.length === 0 || joinBody(shortLines).trim() === "") diagnostics.push(diagnostic("missing-description", "JSDoc must include a short description"));
|
|
2481
|
+
if (index < lines.length && lines[index]?.trim() === "") {
|
|
2482
|
+
const next = lines[index + 1];
|
|
2483
|
+
if (next !== void 0 && !isHeadingLine(next)) diagnostics.push(diagnostic("multiple-description-paragraphs", "JSDoc short description must be one paragraph"));
|
|
2484
|
+
}
|
|
2485
|
+
while (index < lines.length) {
|
|
2486
|
+
if (lines[index]?.trim() !== "") {
|
|
2487
|
+
diagnostics.push(diagnostic("invalid-spacing", "JSDoc sections must be separated by exactly one blank line"));
|
|
2488
|
+
break;
|
|
2489
|
+
}
|
|
2490
|
+
if (lines[index + 1]?.trim() === "") {
|
|
2491
|
+
diagnostics.push(diagnostic("invalid-spacing", "JSDoc sections must be separated by exactly one blank line"));
|
|
2492
|
+
while (lines[index + 1]?.trim() === "") index++;
|
|
2493
|
+
}
|
|
2494
|
+
index++;
|
|
2495
|
+
if (index >= lines.length) break;
|
|
2496
|
+
const line = lines[index]?.trim() ?? "";
|
|
2497
|
+
if (line.startsWith("**Example**")) {
|
|
2498
|
+
examplesStarted = true;
|
|
2499
|
+
const result = validateExample(lines, index);
|
|
2500
|
+
diagnostics.push(...result.diagnostics);
|
|
2501
|
+
if (result.title !== void 0) {
|
|
2502
|
+
const key = result.title.toLowerCase();
|
|
2503
|
+
if (exampleTitles.has(key)) diagnostics.push(diagnostic("duplicate-example", `Duplicate example title: ${result.title}`));
|
|
2504
|
+
exampleTitles.add(key);
|
|
2505
|
+
}
|
|
2506
|
+
index = result.nextIndex;
|
|
2507
|
+
continue;
|
|
2508
|
+
}
|
|
2509
|
+
const sectionOrder = standardHeadings.indexOf(line);
|
|
2510
|
+
if (sectionOrder >= 0) {
|
|
2511
|
+
if (examplesStarted) diagnostics.push(diagnostic("section-after-example", `${line} must appear before examples`));
|
|
2512
|
+
if (sectionOrder <= currentSectionOrder || seenSections.has(line)) diagnostics.push(diagnostic("section-out-of-order", `${line} is out of order or duplicated`));
|
|
2513
|
+
currentSectionOrder = Math.max(currentSectionOrder, sectionOrder);
|
|
2514
|
+
seenSections.add(line);
|
|
2515
|
+
const result = validateSection(lines, index);
|
|
2516
|
+
diagnostics.push(...result.diagnostics);
|
|
2517
|
+
if (line === "**When to use**" && !whenToUsePrefixes.some((prefix) => result.body.trimStart() === prefix || result.body.trimStart().startsWith(`${prefix} `))) diagnostics.push(diagnostic("when-to-use-format", "**When to use** must start with `Use to`, `Use when`, `Use as`, or `Use with`"));
|
|
2518
|
+
index = result.nextIndex;
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
if (isNearMissHeading(line) || isForbiddenMarkdownHeading(line)) diagnostics.push(diagnostic("invalid-heading", `Invalid JSDoc section heading: ${line}`));
|
|
2522
|
+
else if (isBoldOnlyLine(line)) diagnostics.push(diagnostic("unknown-heading", `Unknown JSDoc section heading: ${line}`));
|
|
2523
|
+
else diagnostics.push(diagnostic("invalid-description", "JSDoc description content must appear under a standard section heading"));
|
|
2524
|
+
index++;
|
|
2525
|
+
}
|
|
2526
|
+
return diagnostics;
|
|
2527
|
+
}
|
|
2528
|
+
function validateTags(tags) {
|
|
2529
|
+
const diagnostics = [];
|
|
2530
|
+
const values = /* @__PURE__ */ new Map();
|
|
2531
|
+
let previousOrder = -1;
|
|
2532
|
+
for (const tag of tags) {
|
|
2533
|
+
if (tag.name === "internal") continue;
|
|
2534
|
+
if (tag.name === "example") {
|
|
2535
|
+
diagnostics.push(diagnostic("forbidden-tag", "@example is not allowed; use a canonical **Example** (Title) section"));
|
|
2536
|
+
continue;
|
|
2537
|
+
}
|
|
2538
|
+
const order = tagOrder.get(tag.name);
|
|
2539
|
+
if (order === void 0) {
|
|
2540
|
+
diagnostics.push(diagnostic("forbidden-tag", `@${tag.name} is not allowed in public declaration JSDoc`));
|
|
2541
|
+
continue;
|
|
2542
|
+
}
|
|
2543
|
+
if (order < previousOrder) diagnostics.push(diagnostic("tag-out-of-order", `@${tag.name} is out of order in JSDoc`));
|
|
2544
|
+
previousOrder = Math.max(previousOrder, order);
|
|
2545
|
+
values.set(tag.name, [...values.get(tag.name) ?? [], tag.value.trim()]);
|
|
2546
|
+
}
|
|
2547
|
+
for (const tag of [
|
|
2548
|
+
"deprecated",
|
|
2549
|
+
"category",
|
|
2550
|
+
"since"
|
|
2551
|
+
]) if ((values.get(tag)?.length ?? 0) > 1) diagnostics.push(diagnostic("duplicate-tag", `JSDoc blocks may contain at most one @${tag} tag`));
|
|
2552
|
+
for (const value of values.get("see") ?? []) if (value === "") diagnostics.push(diagnostic("empty-tag", "@see must include a value"));
|
|
2553
|
+
if (values.get("deprecated")?.[0] === "") diagnostics.push(diagnostic("empty-tag", "@deprecated must include a message"));
|
|
2554
|
+
const category = values.get("category")?.[0];
|
|
2555
|
+
if (category === void 0) diagnostics.push(diagnostic("missing-tag", "Public JSDoc must include @category"));
|
|
2556
|
+
else if (category === "") diagnostics.push(diagnostic("empty-tag", "@category must include a value"));
|
|
2557
|
+
const since = values.get("since")?.[0];
|
|
2558
|
+
if (since === void 0) diagnostics.push(diagnostic("missing-tag", "Public JSDoc must include @since"));
|
|
2559
|
+
else if (!stableSemver.test(since)) diagnostics.push(diagnostic("invalid-since", "@since must be a stable semver version like 1.2.3"));
|
|
2560
|
+
return diagnostics;
|
|
2561
|
+
}
|
|
2562
|
+
function validateJSDoc(comment) {
|
|
2563
|
+
const lines = normalizeJSDoc(comment);
|
|
2564
|
+
const tags = parseTags(lines);
|
|
2565
|
+
if (tags.some((tag) => tag.name === "internal")) return [];
|
|
2566
|
+
const diagnostics = [];
|
|
2567
|
+
const firstTagLine = tags[0]?.line ?? lines.length;
|
|
2568
|
+
const content = lines.slice(0, firstTagLine);
|
|
2569
|
+
if (lines.length === 0) diagnostics.push(diagnostic("missing-description", "JSDoc must include a short description"));
|
|
2570
|
+
if (tags.length > 0) {
|
|
2571
|
+
if (content.at(-1)?.trim() !== "" || content.length < 2 || content[content.length - 2]?.trim() === "") diagnostics.push(diagnostic("invalid-spacing", "JSDoc tags must be separated from description content by exactly one blank line"));
|
|
2572
|
+
}
|
|
2573
|
+
if (content[0]?.trim() === "") diagnostics.push(diagnostic("leading-blank", "JSDoc must not start with a blank line"));
|
|
2574
|
+
const description = tags.length > 0 && content.at(-1)?.trim() === "" ? content.slice(0, -1) : content;
|
|
2575
|
+
if (description.at(-1)?.trim() === "") diagnostics.push(diagnostic("trailing-blank", "JSDoc description must not end with a blank line"));
|
|
2576
|
+
diagnostics.push(...validateDescription(description), ...validateTags(tags));
|
|
2577
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2578
|
+
return diagnostics.filter((item) => {
|
|
2579
|
+
const key = `${item.code}:${item.message}`;
|
|
2580
|
+
if (seen.has(key)) return false;
|
|
2581
|
+
seen.add(key);
|
|
2582
|
+
return true;
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
function getJSDoc(sourceCode, node) {
|
|
2586
|
+
const comment = sourceCode.getCommentsBefore(node).at(-1);
|
|
2587
|
+
return comment?.type === "Block" && comment.value.startsWith("*") ? comment : void 0;
|
|
2588
|
+
}
|
|
2589
|
+
/** Require exported declarations to use the public JSDoc format enforced by Effect. */
|
|
2590
|
+
const requirePublicJSDocRule = defineRule({
|
|
2591
|
+
meta: {
|
|
2592
|
+
type: "problem",
|
|
2593
|
+
docs: { description: "Require exported declarations to use Effect-style public API JSDoc with canonical sections and tags." },
|
|
2594
|
+
messages: {
|
|
2595
|
+
invalidJSDoc: "{{message}}",
|
|
2596
|
+
missingJSDoc: "Public declarations require Effect-style JSDoc with a description, @category, and @since."
|
|
2597
|
+
}
|
|
2598
|
+
},
|
|
2599
|
+
createOnce(context) {
|
|
2600
|
+
const checkedFunctionOverloads = /* @__PURE__ */ new Set();
|
|
2601
|
+
const check = (node) => {
|
|
2602
|
+
const comment = getJSDoc(context.sourceCode, node);
|
|
2603
|
+
if (comment === void 0) {
|
|
2604
|
+
context.report({
|
|
2605
|
+
node,
|
|
2606
|
+
messageId: "missingJSDoc"
|
|
2607
|
+
});
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
for (const item of validateJSDoc(comment)) context.report({
|
|
2611
|
+
node,
|
|
2612
|
+
messageId: "invalidJSDoc",
|
|
2613
|
+
data: { message: item.message }
|
|
2614
|
+
});
|
|
2615
|
+
};
|
|
2616
|
+
return { ExportNamedDeclaration(node) {
|
|
2617
|
+
const declaration = node.declaration;
|
|
2618
|
+
if (declaration !== null && "params" in declaration && declaration.id?.type === "Identifier") {
|
|
2619
|
+
if (checkedFunctionOverloads.has(declaration.id.name)) return;
|
|
2620
|
+
checkedFunctionOverloads.add(declaration.id.name);
|
|
2621
|
+
}
|
|
2622
|
+
if (node.declaration !== null) {
|
|
2623
|
+
check(node);
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
for (const specifier of node.specifiers) check(specifier);
|
|
2627
|
+
} };
|
|
2628
|
+
}
|
|
2629
|
+
});
|
|
2630
|
+
//#endregion
|
|
2631
|
+
//#region src/rules/require-test-location.ts
|
|
2632
|
+
/** Place named test files under the owning package's sibling test directory. */
|
|
2633
|
+
const requireTestLocationRule = defineRule({
|
|
2634
|
+
meta: {
|
|
2635
|
+
type: "problem",
|
|
2636
|
+
docs: { description: "Require .test filenames under the nearest package's test/ directory." },
|
|
2637
|
+
schema: [],
|
|
2638
|
+
messages: { location: "Place this test under its package's test/ directory, alongside src/, using a .test filename." }
|
|
2639
|
+
},
|
|
2640
|
+
createOnce(context) {
|
|
2641
|
+
return { Program(node) {
|
|
2642
|
+
if (!isTestFile(context.filename)) return;
|
|
2643
|
+
const owner = packageOwner(context.filename);
|
|
2644
|
+
if (!owner) return;
|
|
2645
|
+
const path = packagePath(owner, context.filename);
|
|
2646
|
+
if (!path.startsWith("test/") || /\.spec\.(?:[cm]?[jt]s|[jt]sx)$/u.test(path)) context.report({
|
|
2647
|
+
node,
|
|
2648
|
+
messageId: "location"
|
|
2649
|
+
});
|
|
2650
|
+
} };
|
|
2651
|
+
}
|
|
2652
|
+
});
|
|
2653
|
+
//#endregion
|
|
2654
|
+
//#region src/index.ts
|
|
2655
|
+
/** Strict Oxlint rules for preserving type evidence and explicit Effect architecture. */
|
|
2656
|
+
const nopeusPlugin = eslintCompatPlugin({
|
|
2657
|
+
meta: { name: "nopeus" },
|
|
2658
|
+
rules: {
|
|
2659
|
+
"no-export-assignment": noExportAssignmentRule,
|
|
2660
|
+
"no-cross-package-internals": noCrossPackageInternalsRule,
|
|
2661
|
+
"no-test-imports": noTestImportsRule,
|
|
2662
|
+
"require-test-location": requireTestLocationRule,
|
|
2663
|
+
"no-effect-runners-in-library": noEffectRunnersInLibraryRule,
|
|
2664
|
+
"no-fallible-effect-promise": noFallibleEffectPromiseRule,
|
|
2665
|
+
"no-inline-live-layer": noInlineLiveLayerRule,
|
|
2666
|
+
"no-module-level-mutable-state": noModuleLevelMutableStateRule,
|
|
2667
|
+
"require-fetch-abort-signal": requireFetchAbortSignalRule,
|
|
2668
|
+
"no-unscoped-fork": noUnscopedForkRule,
|
|
2669
|
+
"no-untyped-effect-errors": noUntypedEffectErrorsRule,
|
|
2670
|
+
"no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule,
|
|
2671
|
+
"no-known-value-widening": noKnownValueWideningRule,
|
|
2672
|
+
"no-module-mocking": noModuleMockingRule,
|
|
2673
|
+
"no-object-parameters": noObjectParametersRule,
|
|
2674
|
+
"no-reflect-apply": noReflectApplyRule,
|
|
2675
|
+
"no-reflect-get": noReflectGetRule,
|
|
2676
|
+
"no-runtime-typeof": noRuntimeTypeofRule,
|
|
2677
|
+
"no-type-assertions": noTypeAssertionsRule,
|
|
2678
|
+
"no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule,
|
|
2679
|
+
"no-unknown-returns": noUnknownReturnsRule,
|
|
2680
|
+
"no-unknown-type-aliases": noUnknownTypeAliasesRule,
|
|
2681
|
+
"prefer-effect-platform-services": preferEffectPlatformServicesRule,
|
|
2682
|
+
"prefer-effect-void": preferEffectVoidRule,
|
|
2683
|
+
"require-public-jsdoc": requirePublicJSDocRule,
|
|
2684
|
+
"require-effect-fn-name": requireEffectFnNameRule,
|
|
2685
|
+
"require-effect-namespace": requireEffectNamespaceRule,
|
|
2686
|
+
"require-service-key-prefix": requireServiceKeyPrefixRule,
|
|
2687
|
+
"require-service-constructor-names": requireServiceConstructorNamesRule
|
|
2688
|
+
}
|
|
2689
|
+
});
|
|
2690
|
+
//#endregion
|
|
2691
|
+
export { nopeusPlugin as default };
|
|
2692
|
+
|
|
2693
|
+
//# sourceMappingURL=index.mjs.map
|