better-ship 0.35.0 → 0.36.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/dist/tanstack/oxlint.d.ts +10 -0
- package/dist/tanstack/oxlint.d.ts.map +1 -0
- package/dist/tanstack/oxlint.js +256 -0
- package/dist/tanstack/oxlint.js.map +1 -0
- package/package.json +8 -5
- package/src/infrastructure/tanstack/oxlint/form-options.ts +58 -0
- package/src/infrastructure/tanstack/oxlint/form-schema-validators.ts +57 -0
- package/src/infrastructure/tanstack/oxlint/form-stable-default-values.ts +46 -0
- package/src/infrastructure/tanstack/oxlint/index.ts +25 -0
- package/src/infrastructure/tanstack/oxlint/no-field-validators.ts +40 -0
- package/src/infrastructure/tanstack/oxlint/no-submit-branching.ts +38 -0
- package/src/infrastructure/tanstack/oxlint/query-key-from-factory.ts +36 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/infrastructure/tanstack/oxlint/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Oxlint plugin for TanStack Form and Query.
|
|
4
|
+
* Enable the `tanstack/*` rules from `jsPlugins`.
|
|
5
|
+
* Ported from Suparelay `packages/tanstack-lint`. The rule ids are unchanged.
|
|
6
|
+
*/
|
|
7
|
+
declare const tanstackPlugin: import("@oxlint/plugins").Plugin;
|
|
8
|
+
//#endregion
|
|
9
|
+
export { tanstackPlugin as default };
|
|
10
|
+
//# sourceMappingURL=oxlint.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oxlint.d.ts","names":[],"sources":["../../src/infrastructure/tanstack/oxlint/index.ts"],"mappings":";;;;;;cAaM,0CAAc"}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { defineRule, eslintCompatPlugin } from "@oxlint/plugins";
|
|
2
|
+
|
|
3
|
+
//#region src/infrastructure/tanstack/oxlint/form-options.ts
|
|
4
|
+
const FORM_HOOKS = /* @__PURE__ */ new Set(["useForm", "useAppForm"]);
|
|
5
|
+
const SAFETY = /(?:^|[^\p{L}\p{N}_])SAFETY\s*:\s*\S/u;
|
|
6
|
+
/** The options object of a `useForm({ … })` or `useAppForm({ … })` call. */
|
|
7
|
+
function formOptions(node) {
|
|
8
|
+
if (node.callee.type !== "Identifier" || !FORM_HOOKS.has(node.callee.name)) return null;
|
|
9
|
+
const [options] = node.arguments;
|
|
10
|
+
return options?.type === "ObjectExpression" ? options : null;
|
|
11
|
+
}
|
|
12
|
+
/** A plain `name: value` property of an object literal. */
|
|
13
|
+
function propertyNamed(object, name) {
|
|
14
|
+
for (const property of object.properties) {
|
|
15
|
+
if (property.type !== "Property" || property.computed) continue;
|
|
16
|
+
const key = property.key;
|
|
17
|
+
if (key.type === "Identifier" && key.name === name) return property;
|
|
18
|
+
if (key.type === "Literal" && key.value === name) return property;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/** A `// SAFETY:` comment directly before the node allows one exception. */
|
|
23
|
+
function hasSafetyComment(sourceCode, node) {
|
|
24
|
+
return sourceCode.getCommentsBefore(node).some((comment) => SAFETY.test(comment.value));
|
|
25
|
+
}
|
|
26
|
+
/** Every node under `root`. Parent links are not followed. */
|
|
27
|
+
function* descendants(root) {
|
|
28
|
+
for (const [key, value] of Object.entries(root)) {
|
|
29
|
+
if (key === "parent") continue;
|
|
30
|
+
const children = Array.isArray(value) ? value : [value];
|
|
31
|
+
for (const child of children) if (isAstNode(child)) {
|
|
32
|
+
yield child;
|
|
33
|
+
yield* descendants(child);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isAstNode(value) {
|
|
38
|
+
return value instanceof Object && "type" in value && "start" in value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/infrastructure/tanstack/oxlint/form-schema-validators.ts
|
|
43
|
+
const DYNAMIC_SLOTS = /* @__PURE__ */ new Set(["onDynamic", "onDynamicAsync"]);
|
|
44
|
+
/**
|
|
45
|
+
* Require one named schema on `useForm` and `useAppForm`.
|
|
46
|
+
* `validationLogic` must call `revalidateLogic`. The schema sits on `validators.onDynamic`.
|
|
47
|
+
* A `// SAFETY:` comment before the call allows one form.
|
|
48
|
+
*/
|
|
49
|
+
const formSchemaValidatorsRule = defineRule({
|
|
50
|
+
meta: {
|
|
51
|
+
type: "problem",
|
|
52
|
+
docs: { description: "Require `validationLogic: revalidateLogic(…)` and `validators: { onDynamic: Schema }` on every TanStack form." },
|
|
53
|
+
messages: {
|
|
54
|
+
logic: "Choose the timing with validationLogic: revalidateLogic({ mode, modeAfterSubmission }).",
|
|
55
|
+
missing: "Validate the form with one schema: validators: { onDynamic: Schema }.",
|
|
56
|
+
slot: "Replace {{slot}} with onDynamic; revalidateLogic sets the timing.",
|
|
57
|
+
inline: "Pass a schema, not a function: validators: { onDynamic: Schema }."
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
createOnce(context) {
|
|
61
|
+
return { CallExpression(node) {
|
|
62
|
+
const options = formOptions(node);
|
|
63
|
+
if (options === null || hasSafetyComment(context.sourceCode, node)) return;
|
|
64
|
+
const logic = propertyNamed(options, "validationLogic");
|
|
65
|
+
if (logic === null || logic.value.type !== "CallExpression" || logic.value.callee.type !== "Identifier" || logic.value.callee.name !== "revalidateLogic") context.report({
|
|
66
|
+
node: logic ?? node,
|
|
67
|
+
messageId: "logic"
|
|
68
|
+
});
|
|
69
|
+
const validators = propertyNamed(options, "validators");
|
|
70
|
+
if (validators === null || validators.value.type !== "ObjectExpression") {
|
|
71
|
+
context.report({
|
|
72
|
+
node: validators ?? node,
|
|
73
|
+
messageId: "missing"
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const slot of validators.value.properties) {
|
|
78
|
+
if (slot.type !== "Property" || slot.key.type !== "Identifier") continue;
|
|
79
|
+
if (!DYNAMIC_SLOTS.has(slot.key.name)) context.report({
|
|
80
|
+
node: slot,
|
|
81
|
+
messageId: "slot",
|
|
82
|
+
data: { slot: slot.key.name }
|
|
83
|
+
});
|
|
84
|
+
else if (slot.value.type !== "Identifier" && slot.value.type !== "MemberExpression") context.report({
|
|
85
|
+
node: slot.value,
|
|
86
|
+
messageId: "inline"
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
} };
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/infrastructure/tanstack/oxlint/form-stable-default-values.ts
|
|
95
|
+
/** A call whose result changes between renders, such as the current time or a random id. */
|
|
96
|
+
function isUnstable(node) {
|
|
97
|
+
if (node.type === "NewExpression") return node.callee.type === "Identifier" && node.callee.name === "Date";
|
|
98
|
+
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return false;
|
|
99
|
+
const { object, property } = node.callee;
|
|
100
|
+
if (object.type !== "Identifier" || property.type !== "Identifier") return false;
|
|
101
|
+
return object.name === "Date" && property.name === "now" || object.name === "Math" && property.name === "random" || object.name === "crypto" && property.name === "randomUUID";
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Reject a time or random value written inside `defaultValues`.
|
|
105
|
+
* TanStack Form applies a changed default to an untouched form on the next render.
|
|
106
|
+
*/
|
|
107
|
+
const formStableDefaultValuesRule = defineRule({
|
|
108
|
+
meta: {
|
|
109
|
+
type: "problem",
|
|
110
|
+
docs: { description: "Disallow time or random values computed inline in `defaultValues`." },
|
|
111
|
+
messages: { unstable: "Compute this default once with useState(() => …) or a module constant; an inline value resets the form on render." }
|
|
112
|
+
},
|
|
113
|
+
createOnce(context) {
|
|
114
|
+
return { CallExpression(node) {
|
|
115
|
+
const options = formOptions(node);
|
|
116
|
+
const defaults = options === null ? null : propertyNamed(options, "defaultValues");
|
|
117
|
+
if (defaults === null) return;
|
|
118
|
+
for (const child of [defaults.value, ...descendants(defaults.value)]) if (isUnstable(child)) context.report({
|
|
119
|
+
node: child,
|
|
120
|
+
messageId: "unstable"
|
|
121
|
+
});
|
|
122
|
+
} };
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/infrastructure/tanstack/oxlint/no-field-validators.ts
|
|
128
|
+
const FIELD_COMPONENTS = /* @__PURE__ */ new Set(["Field", "AppField"]);
|
|
129
|
+
/** Reject field-level `validators`. The form schema is the only validation source. */
|
|
130
|
+
const noFieldValidatorsRule = defineRule({
|
|
131
|
+
meta: {
|
|
132
|
+
type: "problem",
|
|
133
|
+
docs: { description: "Disallow field-level `validators`; the form schema validates." },
|
|
134
|
+
messages: { fieldValidators: "Remove validators from the field. Put the rule in the form schema." }
|
|
135
|
+
},
|
|
136
|
+
createOnce(context) {
|
|
137
|
+
return {
|
|
138
|
+
JSXOpeningElement(node) {
|
|
139
|
+
if (node.name.type !== "JSXMemberExpression") return;
|
|
140
|
+
if (!FIELD_COMPONENTS.has(node.name.property.name)) return;
|
|
141
|
+
for (const attribute of node.attributes) if (attribute.type === "JSXAttribute" && attribute.name.type === "JSXIdentifier" && attribute.name.name === "validators") context.report({
|
|
142
|
+
node: attribute,
|
|
143
|
+
messageId: "fieldValidators"
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
CallExpression(node) {
|
|
147
|
+
if (node.callee.type !== "Identifier" || node.callee.name !== "useField") return;
|
|
148
|
+
const [options] = node.arguments;
|
|
149
|
+
if (options?.type !== "ObjectExpression") return;
|
|
150
|
+
const validators = propertyNamed(options, "validators");
|
|
151
|
+
if (validators !== null) context.report({
|
|
152
|
+
node: validators,
|
|
153
|
+
messageId: "fieldValidators"
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/infrastructure/tanstack/oxlint/no-submit-branching.ts
|
|
162
|
+
const BRANCH_OPERATORS = /* @__PURE__ */ new Set([
|
|
163
|
+
"??",
|
|
164
|
+
"||",
|
|
165
|
+
"&&"
|
|
166
|
+
]);
|
|
167
|
+
function isBranch(node) {
|
|
168
|
+
if (node.type === "IfStatement" || node.type === "ConditionalExpression") return true;
|
|
169
|
+
return node.type === "LogicalExpression" && BRANCH_OPERATORS.has(node.operator);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Reject a condition inside `onSubmit`.
|
|
173
|
+
* The schema already accepted the values. A `// SAFETY:` comment on `onSubmit` allows one check.
|
|
174
|
+
*/
|
|
175
|
+
const noSubmitBranchingRule = defineRule({
|
|
176
|
+
meta: {
|
|
177
|
+
type: "problem",
|
|
178
|
+
docs: { description: "Disallow conditions in a TanStack form's `onSubmit`." },
|
|
179
|
+
messages: { branch: "Move this check into the form schema. Use Schema.parse(value) when the submitted type must be narrowed." }
|
|
180
|
+
},
|
|
181
|
+
createOnce(context) {
|
|
182
|
+
return { CallExpression(node) {
|
|
183
|
+
const options = formOptions(node);
|
|
184
|
+
const submit = options === null ? null : propertyNamed(options, "onSubmit");
|
|
185
|
+
if (submit === null || hasSafetyComment(context.sourceCode, submit)) return;
|
|
186
|
+
for (const child of descendants(submit.value)) if (isBranch(child)) context.report({
|
|
187
|
+
node: child,
|
|
188
|
+
messageId: "branch"
|
|
189
|
+
});
|
|
190
|
+
} };
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/infrastructure/tanstack/oxlint/query-key-from-factory.ts
|
|
196
|
+
const CACHE_METHODS = /* @__PURE__ */ new Set([
|
|
197
|
+
"getQueryData",
|
|
198
|
+
"setQueryData",
|
|
199
|
+
"getQueryState",
|
|
200
|
+
"ensureQueryData"
|
|
201
|
+
]);
|
|
202
|
+
/**
|
|
203
|
+
* Reject a query key array written outside a `*-queries.ts` factory.
|
|
204
|
+
* Invalidation then reads the same key the factory stored.
|
|
205
|
+
*/
|
|
206
|
+
const queryKeyFromFactoryRule = defineRule({
|
|
207
|
+
meta: {
|
|
208
|
+
type: "problem",
|
|
209
|
+
docs: { description: "Disallow query key arrays outside `*-queries.ts` factory files." },
|
|
210
|
+
messages: { literal: "Read this key from the entity's query factory, for example entityQueries.lists() or entityQueries.detail(input).queryKey." }
|
|
211
|
+
},
|
|
212
|
+
createOnce(context) {
|
|
213
|
+
const inFactory = () => context.filename.endsWith("-queries.ts");
|
|
214
|
+
return {
|
|
215
|
+
Property(node) {
|
|
216
|
+
if (inFactory() || node.computed || node.key.type !== "Identifier") return;
|
|
217
|
+
if (node.key.name === "queryKey" && node.value.type === "ArrayExpression") context.report({
|
|
218
|
+
node: node.value,
|
|
219
|
+
messageId: "literal"
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
CallExpression(node) {
|
|
223
|
+
if (inFactory() || node.callee.type !== "MemberExpression") return;
|
|
224
|
+
const method = node.callee.property;
|
|
225
|
+
if (method.type !== "Identifier" || !CACHE_METHODS.has(method.name)) return;
|
|
226
|
+
const [key] = node.arguments;
|
|
227
|
+
if (key?.type === "ArrayExpression") context.report({
|
|
228
|
+
node: key,
|
|
229
|
+
messageId: "literal"
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
//#endregion
|
|
237
|
+
//#region src/infrastructure/tanstack/oxlint/index.ts
|
|
238
|
+
/**
|
|
239
|
+
* Oxlint plugin for TanStack Form and Query.
|
|
240
|
+
* Enable the `tanstack/*` rules from `jsPlugins`.
|
|
241
|
+
* Ported from Suparelay `packages/tanstack-lint`. The rule ids are unchanged.
|
|
242
|
+
*/
|
|
243
|
+
const tanstackPlugin = eslintCompatPlugin({
|
|
244
|
+
meta: { name: "tanstack" },
|
|
245
|
+
rules: {
|
|
246
|
+
"form-schema-validators": formSchemaValidatorsRule,
|
|
247
|
+
"form-stable-default-values": formStableDefaultValuesRule,
|
|
248
|
+
"no-field-validators": noFieldValidatorsRule,
|
|
249
|
+
"no-submit-branching": noSubmitBranchingRule,
|
|
250
|
+
"query-key-from-factory": queryKeyFromFactoryRule
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
//#endregion
|
|
255
|
+
export { tanstackPlugin as default };
|
|
256
|
+
//# sourceMappingURL=oxlint.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oxlint.js","names":[],"sources":["../../src/infrastructure/tanstack/oxlint/form-options.ts","../../src/infrastructure/tanstack/oxlint/form-schema-validators.ts","../../src/infrastructure/tanstack/oxlint/form-stable-default-values.ts","../../src/infrastructure/tanstack/oxlint/no-field-validators.ts","../../src/infrastructure/tanstack/oxlint/no-submit-branching.ts","../../src/infrastructure/tanstack/oxlint/query-key-from-factory.ts","../../src/infrastructure/tanstack/oxlint/index.ts"],"sourcesContent":["import type { ESTree, SourceCode } from '@oxlint/plugins'\n\nconst FORM_HOOKS = new Set(['useForm', 'useAppForm'])\nconst SAFETY = /(?:^|[^\\p{L}\\p{N}_])SAFETY\\s*:\\s*\\S/u\n\n/** The options object of a `useForm({ … })` or `useAppForm({ … })` call. */\nexport function formOptions(node: ESTree.CallExpression): ESTree.ObjectExpression | null {\n if (node.callee.type !== 'Identifier' || !FORM_HOOKS.has(node.callee.name)) return null\n const [options] = node.arguments\n return options?.type === 'ObjectExpression' ? options : null\n}\n\n/** A plain `name: value` property of an object literal. */\nexport function propertyNamed(\n object: ESTree.ObjectExpression,\n name: string,\n): ESTree.ObjectProperty | null {\n for (const property of object.properties) {\n if (property.type !== 'Property' || property.computed) continue\n const key = property.key\n if (key.type === 'Identifier' && key.name === name) return property\n if (key.type === 'Literal' && key.value === name) return property\n }\n return null\n}\n\n/** A `// SAFETY:` comment directly before the node allows one exception. */\nexport function hasSafetyComment(sourceCode: SourceCode, node: ESTree.Node): boolean {\n return sourceCode.getCommentsBefore(node).some((comment) => SAFETY.test(comment.value))\n}\n\n/** Every node under `root`. Parent links are not followed. */\nexport function* descendants(root: ESTree.Node): Generator<ESTree.Node> {\n for (const [key, value] of Object.entries(root)) {\n if (key === 'parent') continue\n const children = Array.isArray(value) ? value : [value]\n for (const child of children) {\n if (isAstNode(child)) {\n yield child\n yield* descendants(child)\n }\n }\n }\n}\n\ntype AstChild =\n | { type?: string; start?: number }\n | string\n | number\n | boolean\n | bigint\n | symbol\n | null\n | undefined\n\nfunction isAstNode(value: AstChild): value is ESTree.Node {\n return value instanceof Object && 'type' in value && 'start' in value\n}\n","import { defineRule } from '@oxlint/plugins'\n\nimport { formOptions, hasSafetyComment, propertyNamed } from './form-options.ts'\n\nconst DYNAMIC_SLOTS = new Set(['onDynamic', 'onDynamicAsync'])\n\n/**\n * Require one named schema on `useForm` and `useAppForm`.\n * `validationLogic` must call `revalidateLogic`. The schema sits on `validators.onDynamic`.\n * A `// SAFETY:` comment before the call allows one form.\n */\nexport const formSchemaValidatorsRule = defineRule({\n meta: {\n type: 'problem',\n docs: {\n description:\n 'Require `validationLogic: revalidateLogic(…)` and `validators: { onDynamic: Schema }` on every TanStack form.',\n },\n messages: {\n logic:\n 'Choose the timing with validationLogic: revalidateLogic({ mode, modeAfterSubmission }).',\n missing: 'Validate the form with one schema: validators: { onDynamic: Schema }.',\n slot: 'Replace {{slot}} with onDynamic; revalidateLogic sets the timing.',\n inline: 'Pass a schema, not a function: validators: { onDynamic: Schema }.',\n },\n },\n createOnce(context) {\n return {\n CallExpression(node) {\n const options = formOptions(node)\n if (options === null || hasSafetyComment(context.sourceCode, node)) return\n const logic = propertyNamed(options, 'validationLogic')\n if (\n logic === null ||\n logic.value.type !== 'CallExpression' ||\n logic.value.callee.type !== 'Identifier' ||\n logic.value.callee.name !== 'revalidateLogic'\n ) {\n context.report({ node: logic ?? node, messageId: 'logic' })\n }\n const validators = propertyNamed(options, 'validators')\n if (validators === null || validators.value.type !== 'ObjectExpression') {\n context.report({ node: validators ?? node, messageId: 'missing' })\n return\n }\n for (const slot of validators.value.properties) {\n if (slot.type !== 'Property' || slot.key.type !== 'Identifier') continue\n if (!DYNAMIC_SLOTS.has(slot.key.name)) {\n context.report({ node: slot, messageId: 'slot', data: { slot: slot.key.name } })\n } else if (slot.value.type !== 'Identifier' && slot.value.type !== 'MemberExpression') {\n context.report({ node: slot.value, messageId: 'inline' })\n }\n }\n },\n }\n },\n})\n","import { defineRule } from '@oxlint/plugins'\nimport type { ESTree } from '@oxlint/plugins'\n\nimport { descendants, formOptions, propertyNamed } from './form-options.ts'\n\n/** A call whose result changes between renders, such as the current time or a random id. */\nfunction isUnstable(node: ESTree.Node): boolean {\n if (node.type === 'NewExpression') {\n return node.callee.type === 'Identifier' && node.callee.name === 'Date'\n }\n if (node.type !== 'CallExpression' || node.callee.type !== 'MemberExpression') return false\n const { object, property } = node.callee\n if (object.type !== 'Identifier' || property.type !== 'Identifier') return false\n return (\n (object.name === 'Date' && property.name === 'now') ||\n (object.name === 'Math' && property.name === 'random') ||\n (object.name === 'crypto' && property.name === 'randomUUID')\n )\n}\n\n/**\n * Reject a time or random value written inside `defaultValues`.\n * TanStack Form applies a changed default to an untouched form on the next render.\n */\nexport const formStableDefaultValuesRule = defineRule({\n meta: {\n type: 'problem',\n docs: { description: 'Disallow time or random values computed inline in `defaultValues`.' },\n messages: {\n unstable:\n 'Compute this default once with useState(() => …) or a module constant; an inline value resets the form on render.',\n },\n },\n createOnce(context) {\n return {\n CallExpression(node) {\n const options = formOptions(node)\n const defaults = options === null ? null : propertyNamed(options, 'defaultValues')\n if (defaults === null) return\n for (const child of [defaults.value, ...descendants(defaults.value)]) {\n if (isUnstable(child)) context.report({ node: child, messageId: 'unstable' })\n }\n },\n }\n },\n})\n","import { defineRule } from '@oxlint/plugins'\n\nimport { propertyNamed } from './form-options.ts'\n\nconst FIELD_COMPONENTS = new Set(['Field', 'AppField'])\n\n/** Reject field-level `validators`. The form schema is the only validation source. */\nexport const noFieldValidatorsRule = defineRule({\n meta: {\n type: 'problem',\n docs: { description: 'Disallow field-level `validators`; the form schema validates.' },\n messages: {\n fieldValidators: 'Remove validators from the field. Put the rule in the form schema.',\n },\n },\n createOnce(context) {\n return {\n JSXOpeningElement(node) {\n if (node.name.type !== 'JSXMemberExpression') return\n if (!FIELD_COMPONENTS.has(node.name.property.name)) return\n for (const attribute of node.attributes) {\n if (\n attribute.type === 'JSXAttribute' &&\n attribute.name.type === 'JSXIdentifier' &&\n attribute.name.name === 'validators'\n ) {\n context.report({ node: attribute, messageId: 'fieldValidators' })\n }\n }\n },\n CallExpression(node) {\n if (node.callee.type !== 'Identifier' || node.callee.name !== 'useField') return\n const [options] = node.arguments\n if (options?.type !== 'ObjectExpression') return\n const validators = propertyNamed(options, 'validators')\n if (validators !== null) context.report({ node: validators, messageId: 'fieldValidators' })\n },\n }\n },\n})\n","import { defineRule } from '@oxlint/plugins'\nimport type { ESTree } from '@oxlint/plugins'\n\nimport { descendants, formOptions, hasSafetyComment, propertyNamed } from './form-options.ts'\n\nconst BRANCH_OPERATORS = new Set(['??', '||', '&&'])\n\nfunction isBranch(node: ESTree.Node): boolean {\n if (node.type === 'IfStatement' || node.type === 'ConditionalExpression') return true\n return node.type === 'LogicalExpression' && BRANCH_OPERATORS.has(node.operator)\n}\n\n/**\n * Reject a condition inside `onSubmit`.\n * The schema already accepted the values. A `// SAFETY:` comment on `onSubmit` allows one check.\n */\nexport const noSubmitBranchingRule = defineRule({\n meta: {\n type: 'problem',\n docs: { description: \"Disallow conditions in a TanStack form's `onSubmit`.\" },\n messages: {\n branch:\n 'Move this check into the form schema. Use Schema.parse(value) when the submitted type must be narrowed.',\n },\n },\n createOnce(context) {\n return {\n CallExpression(node) {\n const options = formOptions(node)\n const submit = options === null ? null : propertyNamed(options, 'onSubmit')\n if (submit === null || hasSafetyComment(context.sourceCode, submit)) return\n for (const child of descendants(submit.value)) {\n if (isBranch(child)) context.report({ node: child, messageId: 'branch' })\n }\n },\n }\n },\n})\n","import { defineRule } from '@oxlint/plugins'\n\nconst CACHE_METHODS = new Set(['getQueryData', 'setQueryData', 'getQueryState', 'ensureQueryData'])\n\n/**\n * Reject a query key array written outside a `*-queries.ts` factory.\n * Invalidation then reads the same key the factory stored.\n */\nexport const queryKeyFromFactoryRule = defineRule({\n meta: {\n type: 'problem',\n docs: { description: 'Disallow query key arrays outside `*-queries.ts` factory files.' },\n messages: {\n literal:\n \"Read this key from the entity's query factory, for example entityQueries.lists() or entityQueries.detail(input).queryKey.\",\n },\n },\n createOnce(context) {\n const inFactory = () => context.filename.endsWith('-queries.ts')\n return {\n Property(node) {\n if (inFactory() || node.computed || node.key.type !== 'Identifier') return\n if (node.key.name === 'queryKey' && node.value.type === 'ArrayExpression') {\n context.report({ node: node.value, messageId: 'literal' })\n }\n },\n CallExpression(node) {\n if (inFactory() || node.callee.type !== 'MemberExpression') return\n const method = node.callee.property\n if (method.type !== 'Identifier' || !CACHE_METHODS.has(method.name)) return\n const [key] = node.arguments\n if (key?.type === 'ArrayExpression') context.report({ node: key, messageId: 'literal' })\n },\n }\n },\n})\n","import { eslintCompatPlugin } from '@oxlint/plugins'\n\nimport { formSchemaValidatorsRule } from './form-schema-validators.ts'\nimport { formStableDefaultValuesRule } from './form-stable-default-values.ts'\nimport { noFieldValidatorsRule } from './no-field-validators.ts'\nimport { noSubmitBranchingRule } from './no-submit-branching.ts'\nimport { queryKeyFromFactoryRule } from './query-key-from-factory.ts'\n\n/**\n * Oxlint plugin for TanStack Form and Query.\n * Enable the `tanstack/*` rules from `jsPlugins`.\n * Ported from Suparelay `packages/tanstack-lint`. The rule ids are unchanged.\n */\nconst tanstackPlugin = eslintCompatPlugin({\n meta: { name: 'tanstack' },\n rules: {\n 'form-schema-validators': formSchemaValidatorsRule,\n 'form-stable-default-values': formStableDefaultValuesRule,\n 'no-field-validators': noFieldValidatorsRule,\n 'no-submit-branching': noSubmitBranchingRule,\n 'query-key-from-factory': queryKeyFromFactoryRule,\n },\n})\n\nexport default tanstackPlugin\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://www.schemastore.org/package.json",
|
|
3
3
|
"name": "better-ship",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.36.0",
|
|
5
5
|
"description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"./stripe/payment-webhook": "./dist/stripe/payment-webhook.js",
|
|
65
65
|
"./stripe/request": "./dist/stripe/request.js",
|
|
66
66
|
"./tanstack": "./dist/tanstack.js",
|
|
67
|
+
"./tanstack/oxlint": "./dist/tanstack/oxlint.js",
|
|
67
68
|
"./tanstack/server": "./dist/tanstack/server.js",
|
|
68
69
|
"./testing/outbox-repository": "./dist/testing/outbox-repository.js",
|
|
69
70
|
"./testing/payment-service": "./dist/testing/payment-service.js",
|
|
@@ -87,9 +88,11 @@
|
|
|
87
88
|
"@mdn/browser-compat-data": "^8.1.4",
|
|
88
89
|
"@oxlint/plugins": "^1.86.0",
|
|
89
90
|
"@posthog/react": "^1.11.3",
|
|
90
|
-
"@sentry/cloudflare": "^11.
|
|
91
|
-
"@sentry/tanstackstart-react": "^11.
|
|
91
|
+
"@sentry/cloudflare": "^11.6.0",
|
|
92
|
+
"@sentry/tanstackstart-react": "^11.6.0",
|
|
92
93
|
"@tailwindcss/node": "^4.3.3",
|
|
94
|
+
"@tanstack/react-form": "^1.33.5",
|
|
95
|
+
"@tanstack/react-query": "^5.104.1",
|
|
93
96
|
"@tanstack/react-router": "^1.170.41",
|
|
94
97
|
"@tanstack/react-start": "^1.168.60",
|
|
95
98
|
"@tanstack/start-client-core": "^1.170.34",
|
|
@@ -121,8 +124,8 @@
|
|
|
121
124
|
"@mdn/browser-compat-data": "^8.1.4",
|
|
122
125
|
"@oxlint/plugins": "^1.86.0",
|
|
123
126
|
"@posthog/react": "^1.11.3",
|
|
124
|
-
"@sentry/cloudflare": "^11.
|
|
125
|
-
"@sentry/tanstackstart-react": "^11.
|
|
127
|
+
"@sentry/cloudflare": "^11.6.0",
|
|
128
|
+
"@sentry/tanstackstart-react": "^11.6.0",
|
|
126
129
|
"@tanstack/react-router": "^1.170.41",
|
|
127
130
|
"@tanstack/react-start": "^1.168.60",
|
|
128
131
|
"better-auth": "^1.7.7",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ESTree, SourceCode } from '@oxlint/plugins'
|
|
2
|
+
|
|
3
|
+
const FORM_HOOKS = new Set(['useForm', 'useAppForm'])
|
|
4
|
+
const SAFETY = /(?:^|[^\p{L}\p{N}_])SAFETY\s*:\s*\S/u
|
|
5
|
+
|
|
6
|
+
/** The options object of a `useForm({ … })` or `useAppForm({ … })` call. */
|
|
7
|
+
export function formOptions(node: ESTree.CallExpression): ESTree.ObjectExpression | null {
|
|
8
|
+
if (node.callee.type !== 'Identifier' || !FORM_HOOKS.has(node.callee.name)) return null
|
|
9
|
+
const [options] = node.arguments
|
|
10
|
+
return options?.type === 'ObjectExpression' ? options : null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A plain `name: value` property of an object literal. */
|
|
14
|
+
export function propertyNamed(
|
|
15
|
+
object: ESTree.ObjectExpression,
|
|
16
|
+
name: string,
|
|
17
|
+
): ESTree.ObjectProperty | null {
|
|
18
|
+
for (const property of object.properties) {
|
|
19
|
+
if (property.type !== 'Property' || property.computed) continue
|
|
20
|
+
const key = property.key
|
|
21
|
+
if (key.type === 'Identifier' && key.name === name) return property
|
|
22
|
+
if (key.type === 'Literal' && key.value === name) return property
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A `// SAFETY:` comment directly before the node allows one exception. */
|
|
28
|
+
export function hasSafetyComment(sourceCode: SourceCode, node: ESTree.Node): boolean {
|
|
29
|
+
return sourceCode.getCommentsBefore(node).some((comment) => SAFETY.test(comment.value))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Every node under `root`. Parent links are not followed. */
|
|
33
|
+
export function* descendants(root: ESTree.Node): Generator<ESTree.Node> {
|
|
34
|
+
for (const [key, value] of Object.entries(root)) {
|
|
35
|
+
if (key === 'parent') continue
|
|
36
|
+
const children = Array.isArray(value) ? value : [value]
|
|
37
|
+
for (const child of children) {
|
|
38
|
+
if (isAstNode(child)) {
|
|
39
|
+
yield child
|
|
40
|
+
yield* descendants(child)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type AstChild =
|
|
47
|
+
| { type?: string; start?: number }
|
|
48
|
+
| string
|
|
49
|
+
| number
|
|
50
|
+
| boolean
|
|
51
|
+
| bigint
|
|
52
|
+
| symbol
|
|
53
|
+
| null
|
|
54
|
+
| undefined
|
|
55
|
+
|
|
56
|
+
function isAstNode(value: AstChild): value is ESTree.Node {
|
|
57
|
+
return value instanceof Object && 'type' in value && 'start' in value
|
|
58
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
|
|
3
|
+
import { formOptions, hasSafetyComment, propertyNamed } from './form-options.ts'
|
|
4
|
+
|
|
5
|
+
const DYNAMIC_SLOTS = new Set(['onDynamic', 'onDynamicAsync'])
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Require one named schema on `useForm` and `useAppForm`.
|
|
9
|
+
* `validationLogic` must call `revalidateLogic`. The schema sits on `validators.onDynamic`.
|
|
10
|
+
* A `// SAFETY:` comment before the call allows one form.
|
|
11
|
+
*/
|
|
12
|
+
export const formSchemaValidatorsRule = defineRule({
|
|
13
|
+
meta: {
|
|
14
|
+
type: 'problem',
|
|
15
|
+
docs: {
|
|
16
|
+
description:
|
|
17
|
+
'Require `validationLogic: revalidateLogic(…)` and `validators: { onDynamic: Schema }` on every TanStack form.',
|
|
18
|
+
},
|
|
19
|
+
messages: {
|
|
20
|
+
logic:
|
|
21
|
+
'Choose the timing with validationLogic: revalidateLogic({ mode, modeAfterSubmission }).',
|
|
22
|
+
missing: 'Validate the form with one schema: validators: { onDynamic: Schema }.',
|
|
23
|
+
slot: 'Replace {{slot}} with onDynamic; revalidateLogic sets the timing.',
|
|
24
|
+
inline: 'Pass a schema, not a function: validators: { onDynamic: Schema }.',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
createOnce(context) {
|
|
28
|
+
return {
|
|
29
|
+
CallExpression(node) {
|
|
30
|
+
const options = formOptions(node)
|
|
31
|
+
if (options === null || hasSafetyComment(context.sourceCode, node)) return
|
|
32
|
+
const logic = propertyNamed(options, 'validationLogic')
|
|
33
|
+
if (
|
|
34
|
+
logic === null ||
|
|
35
|
+
logic.value.type !== 'CallExpression' ||
|
|
36
|
+
logic.value.callee.type !== 'Identifier' ||
|
|
37
|
+
logic.value.callee.name !== 'revalidateLogic'
|
|
38
|
+
) {
|
|
39
|
+
context.report({ node: logic ?? node, messageId: 'logic' })
|
|
40
|
+
}
|
|
41
|
+
const validators = propertyNamed(options, 'validators')
|
|
42
|
+
if (validators === null || validators.value.type !== 'ObjectExpression') {
|
|
43
|
+
context.report({ node: validators ?? node, messageId: 'missing' })
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
for (const slot of validators.value.properties) {
|
|
47
|
+
if (slot.type !== 'Property' || slot.key.type !== 'Identifier') continue
|
|
48
|
+
if (!DYNAMIC_SLOTS.has(slot.key.name)) {
|
|
49
|
+
context.report({ node: slot, messageId: 'slot', data: { slot: slot.key.name } })
|
|
50
|
+
} else if (slot.value.type !== 'Identifier' && slot.value.type !== 'MemberExpression') {
|
|
51
|
+
context.report({ node: slot.value, messageId: 'inline' })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
import type { ESTree } from '@oxlint/plugins'
|
|
3
|
+
|
|
4
|
+
import { descendants, formOptions, propertyNamed } from './form-options.ts'
|
|
5
|
+
|
|
6
|
+
/** A call whose result changes between renders, such as the current time or a random id. */
|
|
7
|
+
function isUnstable(node: ESTree.Node): boolean {
|
|
8
|
+
if (node.type === 'NewExpression') {
|
|
9
|
+
return node.callee.type === 'Identifier' && node.callee.name === 'Date'
|
|
10
|
+
}
|
|
11
|
+
if (node.type !== 'CallExpression' || node.callee.type !== 'MemberExpression') return false
|
|
12
|
+
const { object, property } = node.callee
|
|
13
|
+
if (object.type !== 'Identifier' || property.type !== 'Identifier') return false
|
|
14
|
+
return (
|
|
15
|
+
(object.name === 'Date' && property.name === 'now') ||
|
|
16
|
+
(object.name === 'Math' && property.name === 'random') ||
|
|
17
|
+
(object.name === 'crypto' && property.name === 'randomUUID')
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reject a time or random value written inside `defaultValues`.
|
|
23
|
+
* TanStack Form applies a changed default to an untouched form on the next render.
|
|
24
|
+
*/
|
|
25
|
+
export const formStableDefaultValuesRule = defineRule({
|
|
26
|
+
meta: {
|
|
27
|
+
type: 'problem',
|
|
28
|
+
docs: { description: 'Disallow time or random values computed inline in `defaultValues`.' },
|
|
29
|
+
messages: {
|
|
30
|
+
unstable:
|
|
31
|
+
'Compute this default once with useState(() => …) or a module constant; an inline value resets the form on render.',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
createOnce(context) {
|
|
35
|
+
return {
|
|
36
|
+
CallExpression(node) {
|
|
37
|
+
const options = formOptions(node)
|
|
38
|
+
const defaults = options === null ? null : propertyNamed(options, 'defaultValues')
|
|
39
|
+
if (defaults === null) return
|
|
40
|
+
for (const child of [defaults.value, ...descendants(defaults.value)]) {
|
|
41
|
+
if (isUnstable(child)) context.report({ node: child, messageId: 'unstable' })
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { eslintCompatPlugin } from '@oxlint/plugins'
|
|
2
|
+
|
|
3
|
+
import { formSchemaValidatorsRule } from './form-schema-validators.ts'
|
|
4
|
+
import { formStableDefaultValuesRule } from './form-stable-default-values.ts'
|
|
5
|
+
import { noFieldValidatorsRule } from './no-field-validators.ts'
|
|
6
|
+
import { noSubmitBranchingRule } from './no-submit-branching.ts'
|
|
7
|
+
import { queryKeyFromFactoryRule } from './query-key-from-factory.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Oxlint plugin for TanStack Form and Query.
|
|
11
|
+
* Enable the `tanstack/*` rules from `jsPlugins`.
|
|
12
|
+
* Ported from Suparelay `packages/tanstack-lint`. The rule ids are unchanged.
|
|
13
|
+
*/
|
|
14
|
+
const tanstackPlugin = eslintCompatPlugin({
|
|
15
|
+
meta: { name: 'tanstack' },
|
|
16
|
+
rules: {
|
|
17
|
+
'form-schema-validators': formSchemaValidatorsRule,
|
|
18
|
+
'form-stable-default-values': formStableDefaultValuesRule,
|
|
19
|
+
'no-field-validators': noFieldValidatorsRule,
|
|
20
|
+
'no-submit-branching': noSubmitBranchingRule,
|
|
21
|
+
'query-key-from-factory': queryKeyFromFactoryRule,
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export default tanstackPlugin
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
|
|
3
|
+
import { propertyNamed } from './form-options.ts'
|
|
4
|
+
|
|
5
|
+
const FIELD_COMPONENTS = new Set(['Field', 'AppField'])
|
|
6
|
+
|
|
7
|
+
/** Reject field-level `validators`. The form schema is the only validation source. */
|
|
8
|
+
export const noFieldValidatorsRule = defineRule({
|
|
9
|
+
meta: {
|
|
10
|
+
type: 'problem',
|
|
11
|
+
docs: { description: 'Disallow field-level `validators`; the form schema validates.' },
|
|
12
|
+
messages: {
|
|
13
|
+
fieldValidators: 'Remove validators from the field. Put the rule in the form schema.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
createOnce(context) {
|
|
17
|
+
return {
|
|
18
|
+
JSXOpeningElement(node) {
|
|
19
|
+
if (node.name.type !== 'JSXMemberExpression') return
|
|
20
|
+
if (!FIELD_COMPONENTS.has(node.name.property.name)) return
|
|
21
|
+
for (const attribute of node.attributes) {
|
|
22
|
+
if (
|
|
23
|
+
attribute.type === 'JSXAttribute' &&
|
|
24
|
+
attribute.name.type === 'JSXIdentifier' &&
|
|
25
|
+
attribute.name.name === 'validators'
|
|
26
|
+
) {
|
|
27
|
+
context.report({ node: attribute, messageId: 'fieldValidators' })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
CallExpression(node) {
|
|
32
|
+
if (node.callee.type !== 'Identifier' || node.callee.name !== 'useField') return
|
|
33
|
+
const [options] = node.arguments
|
|
34
|
+
if (options?.type !== 'ObjectExpression') return
|
|
35
|
+
const validators = propertyNamed(options, 'validators')
|
|
36
|
+
if (validators !== null) context.report({ node: validators, messageId: 'fieldValidators' })
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
import type { ESTree } from '@oxlint/plugins'
|
|
3
|
+
|
|
4
|
+
import { descendants, formOptions, hasSafetyComment, propertyNamed } from './form-options.ts'
|
|
5
|
+
|
|
6
|
+
const BRANCH_OPERATORS = new Set(['??', '||', '&&'])
|
|
7
|
+
|
|
8
|
+
function isBranch(node: ESTree.Node): boolean {
|
|
9
|
+
if (node.type === 'IfStatement' || node.type === 'ConditionalExpression') return true
|
|
10
|
+
return node.type === 'LogicalExpression' && BRANCH_OPERATORS.has(node.operator)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reject a condition inside `onSubmit`.
|
|
15
|
+
* The schema already accepted the values. A `// SAFETY:` comment on `onSubmit` allows one check.
|
|
16
|
+
*/
|
|
17
|
+
export const noSubmitBranchingRule = defineRule({
|
|
18
|
+
meta: {
|
|
19
|
+
type: 'problem',
|
|
20
|
+
docs: { description: "Disallow conditions in a TanStack form's `onSubmit`." },
|
|
21
|
+
messages: {
|
|
22
|
+
branch:
|
|
23
|
+
'Move this check into the form schema. Use Schema.parse(value) when the submitted type must be narrowed.',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
createOnce(context) {
|
|
27
|
+
return {
|
|
28
|
+
CallExpression(node) {
|
|
29
|
+
const options = formOptions(node)
|
|
30
|
+
const submit = options === null ? null : propertyNamed(options, 'onSubmit')
|
|
31
|
+
if (submit === null || hasSafetyComment(context.sourceCode, submit)) return
|
|
32
|
+
for (const child of descendants(submit.value)) {
|
|
33
|
+
if (isBranch(child)) context.report({ node: child, messageId: 'branch' })
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
|
|
3
|
+
const CACHE_METHODS = new Set(['getQueryData', 'setQueryData', 'getQueryState', 'ensureQueryData'])
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reject a query key array written outside a `*-queries.ts` factory.
|
|
7
|
+
* Invalidation then reads the same key the factory stored.
|
|
8
|
+
*/
|
|
9
|
+
export const queryKeyFromFactoryRule = defineRule({
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'problem',
|
|
12
|
+
docs: { description: 'Disallow query key arrays outside `*-queries.ts` factory files.' },
|
|
13
|
+
messages: {
|
|
14
|
+
literal:
|
|
15
|
+
"Read this key from the entity's query factory, for example entityQueries.lists() or entityQueries.detail(input).queryKey.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
createOnce(context) {
|
|
19
|
+
const inFactory = () => context.filename.endsWith('-queries.ts')
|
|
20
|
+
return {
|
|
21
|
+
Property(node) {
|
|
22
|
+
if (inFactory() || node.computed || node.key.type !== 'Identifier') return
|
|
23
|
+
if (node.key.name === 'queryKey' && node.value.type === 'ArrayExpression') {
|
|
24
|
+
context.report({ node: node.value, messageId: 'literal' })
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
CallExpression(node) {
|
|
28
|
+
if (inFactory() || node.callee.type !== 'MemberExpression') return
|
|
29
|
+
const method = node.callee.property
|
|
30
|
+
if (method.type !== 'Identifier' || !CACHE_METHODS.has(method.name)) return
|
|
31
|
+
const [key] = node.arguments
|
|
32
|
+
if (key?.type === 'ArrayExpression') context.report({ node: key, messageId: 'literal' })
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
})
|