@murky-web/typebuddy 0.1.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.
@@ -0,0 +1,247 @@
1
+ type AstNode = {
2
+ type: string;
3
+ parent?: AstNode;
4
+ [key: string]: unknown;
5
+ };
6
+
7
+ type RuleContext = {
8
+ getSourceCode(): { getText(node: unknown): string };
9
+ report(descriptor: {
10
+ node: unknown;
11
+ messageId: string;
12
+ fix(
13
+ fixer: {
14
+ replaceText(node: unknown, text: string): unknown;
15
+ insertTextBeforeRange(range: [number, number], text: string): unknown;
16
+ },
17
+ ): unknown;
18
+ }): void;
19
+ };
20
+
21
+ function isNode(value: unknown): value is AstNode {
22
+ return typeof value === "object" && value !== null && "type" in value;
23
+ }
24
+
25
+ function isAsyncFunction(node: AstNode): boolean {
26
+ return node.async === true;
27
+ }
28
+
29
+ function hasTypeParameters(value: unknown): value is { params: unknown[] } {
30
+ return isNode(value) && Array.isArray(value.params);
31
+ }
32
+
33
+ const rule = {
34
+ create(context: RuleContext) {
35
+ const sourceCode = context.getSourceCode();
36
+
37
+ function isTypeReference(node: unknown): node is AstNode {
38
+ return isNode(node) && node.type === "TSTypeReference";
39
+ }
40
+
41
+ function getTypeName(node: AstNode): string | null {
42
+ const typeName = node.typeName;
43
+ if (!isNode(typeName) || typeName.type !== "Identifier") return null;
44
+ return typeof typeName.name === "string" ? typeName.name : null;
45
+ }
46
+
47
+ function getTypeArgument(node: AstNode): AstNode | null {
48
+ const typeArguments = node.typeArguments;
49
+ if (!hasTypeParameters(typeArguments)) return null;
50
+ const [firstParam] = typeArguments.params;
51
+ if (!isNode(firstParam)) return null;
52
+ return firstParam;
53
+ }
54
+
55
+ function getPromiseTypeArgument(node: AstNode): AstNode | null {
56
+ if (getTypeName(node) !== "Promise") return null;
57
+ return getTypeArgument(node);
58
+ }
59
+
60
+ function checkReturnType(node: AstNode) {
61
+ if (!isAsyncFunction(node)) return;
62
+ if (!isNode(node.returnType)) return;
63
+
64
+ const typeAnnotation = node.returnType.typeAnnotation;
65
+ if (!isTypeReference(typeAnnotation)) return;
66
+ if (getTypeName(typeAnnotation) !== "Promise") return;
67
+
68
+ const typeName = typeAnnotation.typeName;
69
+ context.report({
70
+ node: typeName,
71
+ messageId: "replaceWithMaybePromise",
72
+ fix(fixer) {
73
+ return fixer.replaceText(typeName, "MaybePromise");
74
+ },
75
+ });
76
+ }
77
+
78
+ function wrapReturnValue(
79
+ node: AstNode,
80
+ isAsync: boolean,
81
+ returnType: AstNode | null,
82
+ ) {
83
+ if (!isAsync) return;
84
+
85
+ const argument = node.argument;
86
+ if (!isNode(argument)) {
87
+ if (returnType?.type === "TSVoidKeyword") {
88
+ context.report({
89
+ node,
90
+ messageId: "returnVoidPromise",
91
+ fix(fixer) {
92
+ return fixer.replaceText(node, "return VOID_PROMISE");
93
+ },
94
+ });
95
+ }
96
+ return;
97
+ }
98
+
99
+ if (
100
+ argument.type === "Identifier" &&
101
+ (argument.name === "VOID_PROMISE" || argument.name === "FAILED_PROMISE")
102
+ ) {
103
+ return;
104
+ }
105
+
106
+ const hasIsErrorProperty =
107
+ argument.type === "ObjectExpression" &&
108
+ Array.isArray(argument.properties) &&
109
+ argument.properties.some(
110
+ (property) =>
111
+ isNode(property) &&
112
+ property.type === "Property" &&
113
+ isNode(property.key) &&
114
+ property.key.type === "Identifier" &&
115
+ property.key.name === "isError",
116
+ );
117
+
118
+ if (!hasIsErrorProperty) {
119
+ context.report({
120
+ node: argument,
121
+ messageId: "wrapReturn",
122
+ fix(fixer) {
123
+ const argumentText = sourceCode.getText(argument);
124
+ return fixer.replaceText(
125
+ argument,
126
+ `{ value: ${argumentText}, isError: false }`,
127
+ );
128
+ },
129
+ });
130
+ }
131
+ }
132
+
133
+ function processTryCatch(node: AstNode) {
134
+ let parent = node.parent;
135
+ let isAsync = false;
136
+ let parentFunction: AstNode | null = null;
137
+
138
+ while (parent) {
139
+ if (
140
+ parent.type === "FunctionDeclaration" ||
141
+ parent.type === "FunctionExpression" ||
142
+ parent.type === "ArrowFunctionExpression"
143
+ ) {
144
+ isAsync = parent.async === true;
145
+ parentFunction = parent;
146
+ break;
147
+ }
148
+ parent = parent.parent;
149
+ }
150
+
151
+ if (!isAsync || !parentFunction) return;
152
+
153
+ let returnType: AstNode | null = null;
154
+ if (isNode(parentFunction.returnType)) {
155
+ const typeAnnotation = parentFunction.returnType.typeAnnotation;
156
+ if (isTypeReference(typeAnnotation)) {
157
+ returnType = getPromiseTypeArgument(typeAnnotation);
158
+ }
159
+ }
160
+
161
+ const blockBody = isNode(node.block) && Array.isArray(node.block.body)
162
+ ? node.block.body
163
+ : [];
164
+ for (const statement of blockBody) {
165
+ if (isNode(statement) && statement.type === "ReturnStatement") {
166
+ wrapReturnValue(statement, isAsync, returnType);
167
+ }
168
+ }
169
+
170
+ if (!isNode(node.handler) || !isNode(node.handler.body)) return;
171
+ const catchBody = Array.isArray(node.handler.body.body)
172
+ ? node.handler.body.body.filter(isNode)
173
+ : [];
174
+
175
+ const hasCorrectReturn = catchBody.some(
176
+ (statement) =>
177
+ statement.type === "ReturnStatement" &&
178
+ isNode(statement.argument) &&
179
+ statement.argument.type === "Identifier" &&
180
+ statement.argument.name === "FAILED_PROMISE",
181
+ );
182
+
183
+ if (!hasCorrectReturn) {
184
+ context.report({
185
+ node: node.handler,
186
+ messageId: "returnFailedPromise",
187
+ fix(fixer) {
188
+ const lastStatement = catchBody.at(-1);
189
+ if (
190
+ lastStatement?.type === "ReturnStatement" &&
191
+ isNode(lastStatement.argument) &&
192
+ lastStatement.argument.type === "ObjectExpression"
193
+ ) {
194
+ return fixer.replaceText(lastStatement, "return FAILED_PROMISE");
195
+ }
196
+
197
+ const handler = node.handler;
198
+ if (!isNode(handler)) {
199
+ return null;
200
+ }
201
+
202
+ const bodyRange = handler.body;
203
+ if (!isNode(bodyRange) || !Array.isArray(bodyRange.range)) {
204
+ return null;
205
+ }
206
+
207
+ return fixer.insertTextBeforeRange(
208
+ [bodyRange.range[1] - 1, bodyRange.range[1] - 1],
209
+ "return FAILED_PROMISE; ",
210
+ );
211
+ },
212
+ });
213
+ }
214
+ }
215
+
216
+ return {
217
+ FunctionDeclaration: checkReturnType,
218
+ FunctionExpression: checkReturnType,
219
+ ArrowFunctionExpression: checkReturnType,
220
+ TSDeclareFunction: checkReturnType,
221
+ TSFunctionType: checkReturnType,
222
+ TSMethodSignature: checkReturnType,
223
+ TryStatement: processTryCatch,
224
+ };
225
+ },
226
+ defaultOptions: [],
227
+ meta: {
228
+ type: "suggestion",
229
+ docs: {
230
+ description:
231
+ "Ensure async functions' return values follow the MaybePromise pattern.",
232
+ },
233
+ fixable: "code",
234
+ schema: [],
235
+ messages: {
236
+ replaceWithMaybePromise:
237
+ "Use 'MaybePromise' instead of 'Promise' as the return type in async functions.",
238
+ wrapReturn: "Wrap return value with { value: value, isError: false }.",
239
+ returnVoidPromise:
240
+ "Return VOID_PROMISE for async functions with Promise<void> return type.",
241
+ returnFailedPromise:
242
+ "Return FAILED_PROMISE in catch block of async functions.",
243
+ },
244
+ },
245
+ };
246
+
247
+ export { rule as errorSafeAsyncRule };
@@ -0,0 +1,57 @@
1
+ const rule = {
2
+ create(context: {
3
+ getSourceCode(): { getText(node: unknown): string };
4
+ report(descriptor: {
5
+ node: unknown;
6
+ messageId: string;
7
+ data: { type: string };
8
+ fix(fixer: { replaceText(node: unknown, text: string): unknown }): unknown;
9
+ }): void;
10
+ }) {
11
+ return {
12
+ TSUnionType(node: {
13
+ types: Array<{ type: string }>;
14
+ }) {
15
+ const types = node.types;
16
+ const sourceCode = context.getSourceCode();
17
+
18
+ const nullType = types.find((typeNode) => typeNode.type === "TSNullKeyword");
19
+ const undefinedType = types.find(
20
+ (typeNode) => typeNode.type === "TSUndefinedKeyword",
21
+ );
22
+ const otherTypes = types.filter(
23
+ (typeNode) =>
24
+ typeNode.type !== "TSNullKeyword" &&
25
+ typeNode.type !== "TSUndefinedKeyword",
26
+ );
27
+
28
+ if (nullType && !undefinedType && otherTypes.length === 1) {
29
+ const typeText = sourceCode.getText(otherTypes[0]);
30
+ context.report({
31
+ node,
32
+ messageId: "useMaybe",
33
+ data: { type: typeText },
34
+ fix(fixer) {
35
+ return fixer.replaceText(node, `Maybe<${typeText}>`);
36
+ },
37
+ });
38
+ }
39
+ },
40
+ };
41
+ },
42
+ defaultOptions: [],
43
+ meta: {
44
+ type: "suggestion",
45
+ hasSuggestions: true,
46
+ docs: {
47
+ description: "Use Maybe<T> for T | null",
48
+ },
49
+ fixable: "code",
50
+ schema: [],
51
+ messages: {
52
+ useMaybe: "Use Maybe<{{type}}> instead of {{type}} | null",
53
+ },
54
+ },
55
+ };
56
+
57
+ export { rule as maybeRule };
@@ -0,0 +1,59 @@
1
+ const rule = {
2
+ create(context: {
3
+ getSourceCode(): { getText(node: unknown): string };
4
+ report(descriptor: {
5
+ node: unknown;
6
+ messageId: string;
7
+ data: { type: string };
8
+ fix(fixer: { replaceText(node: unknown, text: string): unknown }): unknown;
9
+ }): void;
10
+ }) {
11
+ return {
12
+ TSUnionType(node: {
13
+ types: Array<{ type: string }>;
14
+ }) {
15
+ const types = node.types;
16
+ if (types.length !== 3) return;
17
+
18
+ const sourceCode = context.getSourceCode();
19
+ const nullType = types.find((typeNode) => typeNode.type === "TSNullKeyword");
20
+ const undefinedType = types.find(
21
+ (typeNode) => typeNode.type === "TSUndefinedKeyword",
22
+ );
23
+ const otherType = types.find(
24
+ (typeNode) =>
25
+ typeNode.type !== "TSNullKeyword" &&
26
+ typeNode.type !== "TSUndefinedKeyword",
27
+ );
28
+
29
+ if (nullType && undefinedType && otherType) {
30
+ const typeText = sourceCode.getText(otherType);
31
+ context.report({
32
+ node,
33
+ messageId: "useNullable",
34
+ data: { type: typeText },
35
+ fix(fixer) {
36
+ return fixer.replaceText(node, `Nullable<${typeText}>`);
37
+ },
38
+ });
39
+ }
40
+ },
41
+ };
42
+ },
43
+ defaultOptions: [],
44
+ meta: {
45
+ type: "suggestion",
46
+ hasSuggestions: true,
47
+ docs: {
48
+ description: "Use Nullable<T> instead of T | null | undefined",
49
+ },
50
+ fixable: "code",
51
+ schema: [],
52
+ messages: {
53
+ useNullable:
54
+ "Use Nullable<{{type}}> instead of {{type}} | null | undefined",
55
+ },
56
+ },
57
+ };
58
+
59
+ export { rule as nullableRule };
@@ -0,0 +1,54 @@
1
+ const rule = {
2
+ create(context: {
3
+ getSourceCode(): { getText(node: unknown): string };
4
+ report(descriptor: {
5
+ node: unknown;
6
+ messageId: string;
7
+ data: { type: string };
8
+ fix(fixer: { replaceText(node: unknown, text: string): unknown }): unknown;
9
+ }): void;
10
+ }) {
11
+ return {
12
+ TSUnionType(node: {
13
+ types: Array<{ type: string }>;
14
+ }) {
15
+ if (node.types.length !== 2) return;
16
+
17
+ const undefinedType = node.types.find(
18
+ (typeNode) => typeNode.type === "TSUndefinedKeyword",
19
+ );
20
+ const otherType = node.types.find(
21
+ (typeNode) => typeNode.type !== "TSUndefinedKeyword",
22
+ );
23
+
24
+ if (undefinedType && otherType) {
25
+ const sourceCode = context.getSourceCode();
26
+ const typeText = sourceCode.getText(otherType);
27
+
28
+ context.report({
29
+ node,
30
+ messageId: "preferOptional",
31
+ data: { type: typeText },
32
+ fix(fixer) {
33
+ return fixer.replaceText(node, `Optional<${typeText}>`);
34
+ },
35
+ });
36
+ }
37
+ },
38
+ };
39
+ },
40
+ defaultOptions: [],
41
+ meta: {
42
+ type: "suggestion",
43
+ docs: {
44
+ description: "Enforce using Optional<T> instead of T | undefined",
45
+ },
46
+ fixable: "code",
47
+ schema: [],
48
+ messages: {
49
+ preferOptional: "Use Optional<{{type}}> instead of {{type}} | undefined",
50
+ },
51
+ },
52
+ };
53
+
54
+ export { rule as optionalRule };
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export * from "./type_helper.js";
2
+
3
+ export type {
4
+ Failed,
5
+ JsonifiedObject,
6
+ JsonifiedValue,
7
+ Maybe,
8
+ MaybePromise,
9
+ Nullable,
10
+ Optional,
11
+ ResolveMaybe,
12
+ ResolveNullable,
13
+ ResolveOptional,
14
+ Stringified,
15
+ Success,
16
+ } from "./types/index.js";