@sanity/groq-lint 0.0.1 → 0.0.2
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/cli.js +1026 -6
- package/dist/cli.js.map +1 -1
- package/dist/{chunk-5C74HJYX.js → index.cjs} +86 -32
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +103 -0
- package/dist/index.js +1023 -9
- package/dist/index.js.map +1 -1
- package/dist/schema.cjs +87 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +61 -0
- package/package.json +7 -5
- package/dist/chunk-5C74HJYX.js.map +0 -1
- package/dist/cli.d.ts +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,1033 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
initLinter,
|
|
4
|
-
lint
|
|
5
|
-
} from "./chunk-5C74HJYX.js";
|
|
6
2
|
|
|
7
3
|
// src/cli.ts
|
|
8
4
|
import { readFileSync, existsSync } from "fs";
|
|
9
5
|
import { glob } from "fs/promises";
|
|
10
6
|
import { createRequire } from "module";
|
|
7
|
+
|
|
8
|
+
// src/linter.ts
|
|
9
|
+
import { parse } from "groq-js";
|
|
10
|
+
|
|
11
|
+
// src/walker.ts
|
|
12
|
+
function walk(node, visitor, context = { inFilter: false, inProjection: false, parents: [] }) {
|
|
13
|
+
visitor(node, context);
|
|
14
|
+
const childContext = {
|
|
15
|
+
...context,
|
|
16
|
+
parents: [...context.parents, node]
|
|
17
|
+
};
|
|
18
|
+
switch (node.type) {
|
|
19
|
+
case "Filter": {
|
|
20
|
+
walk(node.base, visitor, childContext);
|
|
21
|
+
walk(node.expr, visitor, { ...childContext, inFilter: true });
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
case "Projection": {
|
|
25
|
+
walk(node.base, visitor, childContext);
|
|
26
|
+
walk(node.expr, visitor, { ...childContext, inProjection: true });
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
case "And":
|
|
30
|
+
case "Or":
|
|
31
|
+
case "OpCall": {
|
|
32
|
+
walk(node.left, visitor, childContext);
|
|
33
|
+
walk(node.right, visitor, childContext);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
case "Not":
|
|
37
|
+
case "Neg":
|
|
38
|
+
case "Pos":
|
|
39
|
+
case "Asc":
|
|
40
|
+
case "Desc":
|
|
41
|
+
case "Group": {
|
|
42
|
+
walk(node.base, visitor, childContext);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case "Deref": {
|
|
46
|
+
walk(node.base, visitor, childContext);
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case "AccessAttribute": {
|
|
50
|
+
if (node.base) {
|
|
51
|
+
walk(node.base, visitor, childContext);
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case "AccessElement": {
|
|
56
|
+
walk(node.base, visitor, childContext);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case "Slice": {
|
|
60
|
+
walk(node.base, visitor, childContext);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case "ArrayCoerce": {
|
|
64
|
+
walk(node.base, visitor, childContext);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "FlatMap":
|
|
68
|
+
case "Map": {
|
|
69
|
+
walk(node.base, visitor, childContext);
|
|
70
|
+
walk(node.expr, visitor, childContext);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "Array": {
|
|
74
|
+
for (const element of node.elements) {
|
|
75
|
+
walk(element.value, visitor, childContext);
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "Object": {
|
|
80
|
+
for (const attr of node.attributes) {
|
|
81
|
+
if (attr.type === "ObjectAttributeValue") {
|
|
82
|
+
walk(attr.value, visitor, childContext);
|
|
83
|
+
} else if (attr.type === "ObjectConditionalSplat") {
|
|
84
|
+
walk(attr.condition, visitor, childContext);
|
|
85
|
+
walk(attr.value, visitor, childContext);
|
|
86
|
+
} else if (attr.type === "ObjectSplat") {
|
|
87
|
+
walk(attr.value, visitor, childContext);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "FuncCall": {
|
|
93
|
+
for (const arg of node.args) {
|
|
94
|
+
walk(arg, visitor, childContext);
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case "PipeFuncCall": {
|
|
99
|
+
walk(node.base, visitor, childContext);
|
|
100
|
+
for (const arg of node.args) {
|
|
101
|
+
walk(arg, visitor, childContext);
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case "Select": {
|
|
106
|
+
for (const alt of node.alternatives) {
|
|
107
|
+
walk(alt.condition, visitor, childContext);
|
|
108
|
+
walk(alt.value, visitor, childContext);
|
|
109
|
+
}
|
|
110
|
+
if (node.fallback) {
|
|
111
|
+
walk(node.fallback, visitor, childContext);
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "InRange": {
|
|
116
|
+
walk(node.base, visitor, childContext);
|
|
117
|
+
walk(node.left, visitor, childContext);
|
|
118
|
+
walk(node.right, visitor, childContext);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case "Tuple": {
|
|
122
|
+
for (const member of node.members) {
|
|
123
|
+
walk(member, visitor, childContext);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
// Leaf nodes - no children to walk
|
|
128
|
+
case "Everything":
|
|
129
|
+
case "This":
|
|
130
|
+
case "Parent":
|
|
131
|
+
case "Value":
|
|
132
|
+
case "Parameter":
|
|
133
|
+
case "Context":
|
|
134
|
+
break;
|
|
135
|
+
default: {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/rules/computed-value-in-filter.ts
|
|
142
|
+
var ARITHMETIC_OPS = ["+", "-", "*", "/"];
|
|
143
|
+
function containsParentRef(node) {
|
|
144
|
+
let found = false;
|
|
145
|
+
walk(node, (n) => {
|
|
146
|
+
if (n.type === "Parent") {
|
|
147
|
+
found = true;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return found;
|
|
151
|
+
}
|
|
152
|
+
function isLiteral(node) {
|
|
153
|
+
if (node.type === "Value") return true;
|
|
154
|
+
if (node.type === "Parameter") return true;
|
|
155
|
+
if (node.type === "FuncCall" && node.name === "now") return true;
|
|
156
|
+
if (node.type === "OpCall") {
|
|
157
|
+
const op = node.op;
|
|
158
|
+
if (ARITHMETIC_OPS.includes(op ?? "")) {
|
|
159
|
+
const left = node.left;
|
|
160
|
+
const right = node.right;
|
|
161
|
+
if (left && right && isLiteral(left) && isLiteral(right)) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
function isArithmeticOp(node) {
|
|
169
|
+
return node.type === "OpCall" && ARITHMETIC_OPS.includes(node.op ?? "");
|
|
170
|
+
}
|
|
171
|
+
var computedValueInFilter = {
|
|
172
|
+
id: "computed-value-in-filter",
|
|
173
|
+
name: "Computed Value in Filter",
|
|
174
|
+
description: "Avoid computed values in filters. Indices cannot be used.",
|
|
175
|
+
severity: "warning",
|
|
176
|
+
category: "performance",
|
|
177
|
+
check(ast, context) {
|
|
178
|
+
walk(ast, (node, walkContext) => {
|
|
179
|
+
if (!walkContext.inFilter) return;
|
|
180
|
+
if (isArithmeticOp(node)) {
|
|
181
|
+
if (containsParentRef(node.left) || containsParentRef(node.right)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (isLiteral(node.left) && isLiteral(node.right)) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
context.report({
|
|
188
|
+
message: `Arithmetic operation '${node.op}' in filter prevents index usage.`,
|
|
189
|
+
severity: "warning",
|
|
190
|
+
help: "Consider pre-computing values or adding additional filters to reduce the search space."
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// src/rules/count-in-correlated-subquery.ts
|
|
198
|
+
function containsParentRef2(node) {
|
|
199
|
+
let found = false;
|
|
200
|
+
walk(node, (n) => {
|
|
201
|
+
if (n.type === "Parent") {
|
|
202
|
+
found = true;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
return found;
|
|
206
|
+
}
|
|
207
|
+
var countInCorrelatedSubquery = {
|
|
208
|
+
id: "count-in-correlated-subquery",
|
|
209
|
+
name: "Count in Correlated Subquery",
|
|
210
|
+
description: "count() on correlated subquery can be slow.",
|
|
211
|
+
severity: "info",
|
|
212
|
+
category: "performance",
|
|
213
|
+
check(ast, context) {
|
|
214
|
+
walk(ast, (node) => {
|
|
215
|
+
if (node.type === "FuncCall") {
|
|
216
|
+
const name = node.name;
|
|
217
|
+
if (name !== "count") return;
|
|
218
|
+
const args = node.args ?? [];
|
|
219
|
+
if (args.length !== 1) return;
|
|
220
|
+
const arg = args[0];
|
|
221
|
+
if (!arg) return;
|
|
222
|
+
if (arg.type === "Filter") {
|
|
223
|
+
if (containsParentRef2(arg)) {
|
|
224
|
+
context.report({
|
|
225
|
+
message: "count() on correlated subquery does not execute as an efficient aggregation.",
|
|
226
|
+
severity: "info",
|
|
227
|
+
help: "This pattern may be slow on large datasets. Consider restructuring the query."
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// src/rules/deep-pagination.ts
|
|
237
|
+
var DEEP_PAGINATION_THRESHOLD = 1e3;
|
|
238
|
+
var deepPagination = {
|
|
239
|
+
id: "deep-pagination",
|
|
240
|
+
name: "Deep Pagination",
|
|
241
|
+
description: "Deep pagination with large offsets is slow.",
|
|
242
|
+
severity: "warning",
|
|
243
|
+
category: "performance",
|
|
244
|
+
check(ast, context) {
|
|
245
|
+
walk(ast, (node) => {
|
|
246
|
+
if (node.type === "Slice") {
|
|
247
|
+
const left = node.left;
|
|
248
|
+
if (typeof left === "number" && left >= DEEP_PAGINATION_THRESHOLD) {
|
|
249
|
+
context.report({
|
|
250
|
+
message: `Slice offset of ${left} is deep pagination. This is slow because all skipped documents must be sorted first.`,
|
|
251
|
+
severity: "warning",
|
|
252
|
+
help: 'Use cursor-based pagination with _id instead (e.g., `*[_type == "post" && _id > $lastId] | order(_id)[0...20]`).'
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// src/rules/deep-pagination-param.ts
|
|
261
|
+
var deepPaginationParam = {
|
|
262
|
+
id: "deep-pagination-param",
|
|
263
|
+
name: "Deep Pagination Parameter",
|
|
264
|
+
description: "Slice offset uses a parameter which could cause deep pagination.",
|
|
265
|
+
severity: "info",
|
|
266
|
+
category: "performance",
|
|
267
|
+
check(ast, context) {
|
|
268
|
+
walk(ast, (node) => {
|
|
269
|
+
if (node.type === "Slice") {
|
|
270
|
+
const left = node.left;
|
|
271
|
+
if (left && typeof left === "object" && left.type === "Parameter") {
|
|
272
|
+
const paramNode = left;
|
|
273
|
+
context.report({
|
|
274
|
+
message: `Slice offset uses parameter $${paramNode.name}. If given a large value, this will cause slow deep pagination.`,
|
|
275
|
+
severity: "info",
|
|
276
|
+
help: "Consider using cursor-based pagination with _id instead, or validate the parameter value."
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// src/rules/extremely-large-query.ts
|
|
285
|
+
var HUNDRED_KB = 100 * 1024;
|
|
286
|
+
var extremelyLargeQuery = {
|
|
287
|
+
id: "extremely-large-query",
|
|
288
|
+
name: "Extremely Large Query",
|
|
289
|
+
description: "This query is extremely large and will likely execute very slowly.",
|
|
290
|
+
severity: "error",
|
|
291
|
+
category: "performance",
|
|
292
|
+
supersedes: ["very-large-query"],
|
|
293
|
+
check(_ast, context) {
|
|
294
|
+
if (context.queryLength > HUNDRED_KB) {
|
|
295
|
+
context.report({
|
|
296
|
+
message: `This query is ${formatSize(context.queryLength)}, which is extremely large and will likely execute very slowly. It may be deprioritized by the server.`,
|
|
297
|
+
severity: "error",
|
|
298
|
+
help: "Break the query into smaller parts, simplify projections, or reconsider the data model."
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
function formatSize(bytes) {
|
|
304
|
+
if (bytes >= 1024 * 1024) {
|
|
305
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
306
|
+
}
|
|
307
|
+
if (bytes >= 1024) {
|
|
308
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
309
|
+
}
|
|
310
|
+
return `${bytes} bytes`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/rules/invalid-type-filter.ts
|
|
314
|
+
function levenshteinDistance(a, b) {
|
|
315
|
+
const m = a.length;
|
|
316
|
+
const n = b.length;
|
|
317
|
+
if (m === 0) return n;
|
|
318
|
+
if (n === 0) return m;
|
|
319
|
+
let prevRow = new Array(n + 1);
|
|
320
|
+
let currRow = new Array(n + 1);
|
|
321
|
+
for (let j = 0; j <= n; j++) {
|
|
322
|
+
prevRow[j] = j;
|
|
323
|
+
}
|
|
324
|
+
for (let i = 1; i <= m; i++) {
|
|
325
|
+
currRow[0] = i;
|
|
326
|
+
for (let j = 1; j <= n; j++) {
|
|
327
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
328
|
+
currRow[j] = Math.min(
|
|
329
|
+
(prevRow[j] ?? 0) + 1,
|
|
330
|
+
// deletion
|
|
331
|
+
(currRow[j - 1] ?? 0) + 1,
|
|
332
|
+
// insertion
|
|
333
|
+
(prevRow[j - 1] ?? 0) + cost
|
|
334
|
+
// substitution
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
;
|
|
338
|
+
[prevRow, currRow] = [currRow, prevRow];
|
|
339
|
+
}
|
|
340
|
+
return prevRow[n] ?? 0;
|
|
341
|
+
}
|
|
342
|
+
function findSimilarTypes(typeName, schemaTypes, maxDistance = 3) {
|
|
343
|
+
return schemaTypes.map((t) => ({
|
|
344
|
+
type: t,
|
|
345
|
+
distance: levenshteinDistance(typeName.toLowerCase(), t.toLowerCase())
|
|
346
|
+
})).filter((t) => t.distance <= maxDistance && t.distance > 0).sort((a, b) => a.distance - b.distance).slice(0, 3).map((t) => t.type);
|
|
347
|
+
}
|
|
348
|
+
function getSchemaDocumentTypes(schema) {
|
|
349
|
+
return schema.filter((item) => item.type === "document").map((item) => item.name);
|
|
350
|
+
}
|
|
351
|
+
function isTypeComparison(node) {
|
|
352
|
+
if (node.op !== "==") return null;
|
|
353
|
+
const { left, right } = node;
|
|
354
|
+
if (left.type === "AccessAttribute" && left.name === "_type" && right.type === "Value" && typeof right.value === "string") {
|
|
355
|
+
return { typeName: right.value };
|
|
356
|
+
}
|
|
357
|
+
if (right.type === "AccessAttribute" && right.name === "_type" && left.type === "Value" && typeof left.value === "string") {
|
|
358
|
+
return { typeName: left.value };
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
var invalidTypeFilter = {
|
|
363
|
+
id: "invalid-type-filter",
|
|
364
|
+
name: "Invalid Type Filter",
|
|
365
|
+
description: "Document type in filter does not exist in schema",
|
|
366
|
+
severity: "error",
|
|
367
|
+
category: "correctness",
|
|
368
|
+
requiresSchema: true,
|
|
369
|
+
check(ast, context) {
|
|
370
|
+
const { schema } = context;
|
|
371
|
+
if (!schema) return;
|
|
372
|
+
const documentTypes = getSchemaDocumentTypes(schema);
|
|
373
|
+
const documentTypeSet = new Set(documentTypes);
|
|
374
|
+
walk(ast, (node) => {
|
|
375
|
+
if (node.type !== "OpCall") return;
|
|
376
|
+
const typeComparison = isTypeComparison(node);
|
|
377
|
+
if (!typeComparison) return;
|
|
378
|
+
const { typeName } = typeComparison;
|
|
379
|
+
if (!documentTypeSet.has(typeName)) {
|
|
380
|
+
const similarTypes = findSimilarTypes(typeName, documentTypes);
|
|
381
|
+
const suggestions = similarTypes.map((t) => ({
|
|
382
|
+
description: `Change to "${t}"`,
|
|
383
|
+
replacement: t
|
|
384
|
+
}));
|
|
385
|
+
context.report({
|
|
386
|
+
message: `Document type "${typeName}" does not exist in schema`,
|
|
387
|
+
severity: "error",
|
|
388
|
+
help: similarTypes.length > 0 ? `Did you mean: ${similarTypes.map((t) => `"${t}"`).join(", ")}?` : `Available types: ${documentTypes.slice(0, 5).join(", ")}${documentTypes.length > 5 ? "..." : ""}`,
|
|
389
|
+
suggestions
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// src/rules/join-in-filter.ts
|
|
397
|
+
var joinInFilter = {
|
|
398
|
+
id: "join-in-filter",
|
|
399
|
+
name: "Join in Filter",
|
|
400
|
+
description: "Avoid `->` inside filters. It prevents optimization.",
|
|
401
|
+
severity: "error",
|
|
402
|
+
category: "performance",
|
|
403
|
+
check(ast, context) {
|
|
404
|
+
walk(ast, (node, walkContext) => {
|
|
405
|
+
if (node.type === "Deref" && walkContext.inFilter) {
|
|
406
|
+
context.report({
|
|
407
|
+
message: "Avoid joins (`->`) inside filters. It prevents optimization.",
|
|
408
|
+
severity: "error",
|
|
409
|
+
help: 'Use `field._ref == $id` instead of `field->attr == "value"`'
|
|
410
|
+
// Note: groq-js doesn't expose source positions, so we omit span
|
|
411
|
+
// In a future version, we might add position tracking
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// src/rules/join-to-get-id.ts
|
|
419
|
+
var joinToGetId = {
|
|
420
|
+
id: "join-to-get-id",
|
|
421
|
+
name: "Join to Get ID",
|
|
422
|
+
description: "Avoid using `->` to retrieve `_id`.",
|
|
423
|
+
severity: "info",
|
|
424
|
+
category: "performance",
|
|
425
|
+
check(ast, context) {
|
|
426
|
+
walk(ast, (node) => {
|
|
427
|
+
if (node.type === "AccessAttribute" && node.name === "_id") {
|
|
428
|
+
if (node.base && node.base.type === "Deref") {
|
|
429
|
+
context.report({
|
|
430
|
+
message: "Avoid using `->` to retrieve `_id`. Use `._ref` instead.",
|
|
431
|
+
severity: "info",
|
|
432
|
+
help: "Replace `reference->_id` with `reference._ref` for better performance."
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// src/rules/large-pages.ts
|
|
441
|
+
var LARGE_PAGE_THRESHOLD = 100;
|
|
442
|
+
var largePages = {
|
|
443
|
+
id: "large-pages",
|
|
444
|
+
name: "Large Pages",
|
|
445
|
+
description: "Fetching many results at once can be slow.",
|
|
446
|
+
severity: "warning",
|
|
447
|
+
category: "performance",
|
|
448
|
+
check(ast, context) {
|
|
449
|
+
walk(ast, (node) => {
|
|
450
|
+
if (node.type === "Slice") {
|
|
451
|
+
const left = node.left;
|
|
452
|
+
const right = node.right;
|
|
453
|
+
if ((left === 0 || left === void 0) && typeof right === "number") {
|
|
454
|
+
if (right > LARGE_PAGE_THRESHOLD) {
|
|
455
|
+
context.report({
|
|
456
|
+
message: `Fetching ${right} results at once can be slow.`,
|
|
457
|
+
severity: "warning",
|
|
458
|
+
help: "Consider breaking into smaller batches and/or using cursor-based pagination."
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
// src/rules/many-joins.ts
|
|
468
|
+
var MAX_JOINS = 10;
|
|
469
|
+
var manyJoins = {
|
|
470
|
+
id: "many-joins",
|
|
471
|
+
name: "Many Joins",
|
|
472
|
+
description: "This query uses many joins and may have poor performance.",
|
|
473
|
+
severity: "warning",
|
|
474
|
+
category: "performance",
|
|
475
|
+
check(ast, context) {
|
|
476
|
+
let joinCount = 0;
|
|
477
|
+
walk(ast, (node) => {
|
|
478
|
+
if (node.type === "Deref") {
|
|
479
|
+
joinCount++;
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
if (joinCount > MAX_JOINS) {
|
|
483
|
+
context.report({
|
|
484
|
+
message: `This query uses ${joinCount} joins (->), which may cause poor performance.`,
|
|
485
|
+
severity: "warning",
|
|
486
|
+
help: "Consider denormalizing data, using fewer reference expansions, or splitting into multiple queries."
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// src/rules/match-on-id.ts
|
|
493
|
+
var matchOnId = {
|
|
494
|
+
id: "match-on-id",
|
|
495
|
+
name: "Match on ID",
|
|
496
|
+
description: "`match` on `_id` may not work as expected.",
|
|
497
|
+
severity: "info",
|
|
498
|
+
category: "correctness",
|
|
499
|
+
check(ast, context) {
|
|
500
|
+
walk(ast, (node) => {
|
|
501
|
+
if (node.type === "OpCall") {
|
|
502
|
+
const op = node.op;
|
|
503
|
+
if (op !== "match") return;
|
|
504
|
+
const left = node.left;
|
|
505
|
+
if (left && left.type === "AccessAttribute") {
|
|
506
|
+
const name = left.name;
|
|
507
|
+
if (name === "_id") {
|
|
508
|
+
context.report({
|
|
509
|
+
message: "`match` is designed for full-text matching and may not work as expected on `_id`.",
|
|
510
|
+
severity: "info",
|
|
511
|
+
help: "Consider using `==` for exact matches or `string::startsWith()` for prefix matching."
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// src/rules/non-literal-comparison.ts
|
|
521
|
+
var COMPARISON_OPS = ["==", "!=", "<", ">", "<=", ">="];
|
|
522
|
+
function containsParentRef3(node) {
|
|
523
|
+
let found = false;
|
|
524
|
+
walk(node, (n) => {
|
|
525
|
+
if (n.type === "Parent") {
|
|
526
|
+
found = true;
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
return found;
|
|
530
|
+
}
|
|
531
|
+
function isLiteral2(node) {
|
|
532
|
+
if (node.type === "Value") return true;
|
|
533
|
+
if (node.type === "Parameter") return true;
|
|
534
|
+
if (node.type === "FuncCall" && node.name === "now") return true;
|
|
535
|
+
if (node.type === "OpCall") {
|
|
536
|
+
const op = node.op;
|
|
537
|
+
if (["+", "-", "*", "/"].includes(op ?? "")) {
|
|
538
|
+
const left = node.left;
|
|
539
|
+
const right = node.right;
|
|
540
|
+
if (left && right && isLiteral2(left) && isLiteral2(right)) {
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
var nonLiteralComparison = {
|
|
548
|
+
id: "non-literal-comparison",
|
|
549
|
+
name: "Non-Literal Comparison",
|
|
550
|
+
description: "Comparisons between two non-literal fields are slow.",
|
|
551
|
+
severity: "warning",
|
|
552
|
+
category: "performance",
|
|
553
|
+
check(ast, context) {
|
|
554
|
+
walk(ast, (node, walkContext) => {
|
|
555
|
+
if (!walkContext.inFilter) return;
|
|
556
|
+
if (node.type === "OpCall") {
|
|
557
|
+
const op = node.op;
|
|
558
|
+
if (!COMPARISON_OPS.includes(op ?? "")) return;
|
|
559
|
+
const left = node.left;
|
|
560
|
+
const right = node.right;
|
|
561
|
+
if (!left || !right) return;
|
|
562
|
+
if (containsParentRef3(left) || containsParentRef3(right)) return;
|
|
563
|
+
if (!isLiteral2(left) && !isLiteral2(right)) {
|
|
564
|
+
context.report({
|
|
565
|
+
message: "Comparing two non-literal expressions prevents efficient index usage.",
|
|
566
|
+
severity: "warning",
|
|
567
|
+
help: "Consider adding additional filters to reduce the search space."
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
// src/rules/order-on-expr.ts
|
|
576
|
+
function isSimpleAttribute(node) {
|
|
577
|
+
return node.type === "AccessAttribute";
|
|
578
|
+
}
|
|
579
|
+
function isAllowedWrappedAttribute(node) {
|
|
580
|
+
if (node.type !== "FuncCall") return false;
|
|
581
|
+
const name = node.name;
|
|
582
|
+
const namespace = node.namespace;
|
|
583
|
+
const args = node.args ?? [];
|
|
584
|
+
if (name === "lower" && namespace === "global" && args.length === 1) {
|
|
585
|
+
const arg = args[0];
|
|
586
|
+
return arg !== void 0 && isSimpleAttribute(arg);
|
|
587
|
+
}
|
|
588
|
+
if (name === "dateTime" && namespace === "global" && args.length === 1) {
|
|
589
|
+
const arg = args[0];
|
|
590
|
+
return arg !== void 0 && isSimpleAttribute(arg);
|
|
591
|
+
}
|
|
592
|
+
if (name === "distance" && namespace === "geo" && args.length === 2) {
|
|
593
|
+
const arg0 = args[0];
|
|
594
|
+
const arg1 = args[1];
|
|
595
|
+
return arg0 !== void 0 && arg1 !== void 0 && isSimpleAttribute(arg0) && arg1.type === "Value";
|
|
596
|
+
}
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
function isValidOrderArg(node) {
|
|
600
|
+
if (isSimpleAttribute(node)) return true;
|
|
601
|
+
if (isAllowedWrappedAttribute(node)) return true;
|
|
602
|
+
if (node.type === "Asc" || node.type === "Desc") {
|
|
603
|
+
const base = node.base;
|
|
604
|
+
if (base) {
|
|
605
|
+
return isSimpleAttribute(base) || isAllowedWrappedAttribute(base);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
var orderOnExpr = {
|
|
611
|
+
id: "order-on-expr",
|
|
612
|
+
name: "Order on Expression",
|
|
613
|
+
description: "Ordering on computed values is slow.",
|
|
614
|
+
severity: "warning",
|
|
615
|
+
category: "performance",
|
|
616
|
+
check(ast, context) {
|
|
617
|
+
walk(ast, (node) => {
|
|
618
|
+
if (node.type === "PipeFuncCall") {
|
|
619
|
+
const name = node.name;
|
|
620
|
+
if (name !== "order") return;
|
|
621
|
+
const args = node.args ?? [];
|
|
622
|
+
for (const arg of args) {
|
|
623
|
+
if (!isValidOrderArg(arg)) {
|
|
624
|
+
context.report({
|
|
625
|
+
message: "Ordering on computed expression prevents index usage.",
|
|
626
|
+
severity: "warning",
|
|
627
|
+
help: "Use simple attributes or allowed functions (lower, dateTime, geo::distance) for ordering."
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// src/rules/repeated-dereference.ts
|
|
637
|
+
function getDerefBaseName(node) {
|
|
638
|
+
if (node.type !== "Deref") return null;
|
|
639
|
+
const base = node.base;
|
|
640
|
+
if (!base) return null;
|
|
641
|
+
let current = base;
|
|
642
|
+
while (current.type === "AccessAttribute" && current.base) {
|
|
643
|
+
current = current.base;
|
|
644
|
+
}
|
|
645
|
+
if (current.type === "AccessAttribute") {
|
|
646
|
+
return current.name ?? null;
|
|
647
|
+
}
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
var repeatedDereference = {
|
|
651
|
+
id: "repeated-dereference",
|
|
652
|
+
name: "Repeated Dereference",
|
|
653
|
+
description: "Repeatedly resolving the same reference is inefficient.",
|
|
654
|
+
severity: "info",
|
|
655
|
+
category: "performance",
|
|
656
|
+
check(ast, context) {
|
|
657
|
+
walk(ast, (node, _walkContext) => {
|
|
658
|
+
if (node.type !== "Projection") return;
|
|
659
|
+
const projectionExpr = node.expr;
|
|
660
|
+
if (!projectionExpr) return;
|
|
661
|
+
const derefCounts = /* @__PURE__ */ new Map();
|
|
662
|
+
walk(projectionExpr, (innerNode) => {
|
|
663
|
+
if (innerNode.type === "Deref") {
|
|
664
|
+
const baseName = getDerefBaseName(innerNode);
|
|
665
|
+
if (baseName) {
|
|
666
|
+
derefCounts.set(baseName, (derefCounts.get(baseName) ?? 0) + 1);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
for (const [name, count] of derefCounts) {
|
|
671
|
+
if (count > 1) {
|
|
672
|
+
context.report({
|
|
673
|
+
message: `Reference '${name}' is dereferenced ${count} times. Consider a single sub-projection.`,
|
|
674
|
+
severity: "info",
|
|
675
|
+
help: `Replace multiple '${name}->...' with '${name}->{ ... }' to resolve the reference once.`
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
// src/rules/unknown-field.ts
|
|
684
|
+
function levenshteinDistance2(a, b) {
|
|
685
|
+
const m = a.length;
|
|
686
|
+
const n = b.length;
|
|
687
|
+
if (m === 0) return n;
|
|
688
|
+
if (n === 0) return m;
|
|
689
|
+
let prevRow = new Array(n + 1);
|
|
690
|
+
let currRow = new Array(n + 1);
|
|
691
|
+
for (let j = 0; j <= n; j++) {
|
|
692
|
+
prevRow[j] = j;
|
|
693
|
+
}
|
|
694
|
+
for (let i = 1; i <= m; i++) {
|
|
695
|
+
currRow[0] = i;
|
|
696
|
+
for (let j = 1; j <= n; j++) {
|
|
697
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
698
|
+
currRow[j] = Math.min(
|
|
699
|
+
(prevRow[j] ?? 0) + 1,
|
|
700
|
+
(currRow[j - 1] ?? 0) + 1,
|
|
701
|
+
(prevRow[j - 1] ?? 0) + cost
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
;
|
|
705
|
+
[prevRow, currRow] = [currRow, prevRow];
|
|
706
|
+
}
|
|
707
|
+
return prevRow[n] ?? 0;
|
|
708
|
+
}
|
|
709
|
+
function findSimilarFields(fieldName, availableFields, maxDistance = 3) {
|
|
710
|
+
return availableFields.map((f) => ({
|
|
711
|
+
field: f,
|
|
712
|
+
distance: levenshteinDistance2(fieldName.toLowerCase(), f.toLowerCase())
|
|
713
|
+
})).filter((f) => f.distance <= maxDistance && f.distance > 0).sort((a, b) => a.distance - b.distance).slice(0, 3).map((f) => f.field);
|
|
714
|
+
}
|
|
715
|
+
function extractTypeFromFilter(node) {
|
|
716
|
+
if (node.type === "OpCall") {
|
|
717
|
+
const opNode = node;
|
|
718
|
+
if (opNode.op !== "==") return null;
|
|
719
|
+
const { left, right } = opNode;
|
|
720
|
+
if (left.type === "AccessAttribute" && left.name === "_type" && right.type === "Value" && typeof right.value === "string") {
|
|
721
|
+
return right.value;
|
|
722
|
+
}
|
|
723
|
+
if (right.type === "AccessAttribute" && right.name === "_type" && left.type === "Value" && typeof left.value === "string") {
|
|
724
|
+
return left.value;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (node.type === "And") {
|
|
728
|
+
const leftType = extractTypeFromFilter(node.left);
|
|
729
|
+
if (leftType) return leftType;
|
|
730
|
+
return extractTypeFromFilter(node.right);
|
|
731
|
+
}
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
function findDocumentTypeFromFilter(filterBase) {
|
|
735
|
+
if (filterBase.type === "Filter") {
|
|
736
|
+
return extractTypeFromFilter(filterBase.expr);
|
|
737
|
+
}
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
function getFieldsForType(schema, typeName) {
|
|
741
|
+
const doc = schema.find((item) => item.type === "document" && item.name === typeName);
|
|
742
|
+
if (!doc?.attributes) return [];
|
|
743
|
+
return Object.keys(doc.attributes);
|
|
744
|
+
}
|
|
745
|
+
var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["_id", "_type", "_rev", "_createdAt", "_updatedAt"]);
|
|
746
|
+
function checkObjectFields(objNode, documentType, availableFieldsSet, schemaFields, context) {
|
|
747
|
+
for (const attr of objNode.attributes) {
|
|
748
|
+
if (attr.type === "ObjectAttributeValue") {
|
|
749
|
+
const value = attr.value;
|
|
750
|
+
if (value.type === "AccessAttribute" && !value.base) {
|
|
751
|
+
const fieldName = value.name;
|
|
752
|
+
if (availableFieldsSet.has(fieldName)) continue;
|
|
753
|
+
const similarFields = findSimilarFields(fieldName, [...availableFieldsSet]);
|
|
754
|
+
const suggestions = similarFields.map((f) => ({
|
|
755
|
+
description: `Change to "${f}"`,
|
|
756
|
+
replacement: f
|
|
757
|
+
}));
|
|
758
|
+
context.report({
|
|
759
|
+
message: `Field "${fieldName}" does not exist on type "${documentType}"`,
|
|
760
|
+
severity: "warning",
|
|
761
|
+
help: similarFields.length > 0 ? `Did you mean: ${similarFields.map((f) => `"${f}"`).join(", ")}?` : `Available fields: ${schemaFields.slice(0, 5).join(", ")}${schemaFields.length > 5 ? "..." : ""}`,
|
|
762
|
+
suggestions
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
var unknownField = {
|
|
769
|
+
id: "unknown-field",
|
|
770
|
+
name: "Unknown Field",
|
|
771
|
+
description: "Field in projection does not exist in schema",
|
|
772
|
+
severity: "warning",
|
|
773
|
+
category: "correctness",
|
|
774
|
+
requiresSchema: true,
|
|
775
|
+
check(ast, context) {
|
|
776
|
+
const { schema } = context;
|
|
777
|
+
if (!schema) return;
|
|
778
|
+
walk(ast, (node) => {
|
|
779
|
+
if (node.type === "Map") {
|
|
780
|
+
const mapNode = node;
|
|
781
|
+
const documentType = findDocumentTypeFromFilter(mapNode.base);
|
|
782
|
+
if (!documentType) return;
|
|
783
|
+
if (mapNode.expr.type !== "Projection") return;
|
|
784
|
+
const projection = mapNode.expr;
|
|
785
|
+
if (projection.expr.type !== "Object") return;
|
|
786
|
+
const objNode = projection.expr;
|
|
787
|
+
const schemaFields = getFieldsForType(
|
|
788
|
+
schema,
|
|
789
|
+
documentType
|
|
790
|
+
);
|
|
791
|
+
if (schemaFields.length === 0) return;
|
|
792
|
+
const availableFields = [...schemaFields, ...BUILT_IN_FIELDS];
|
|
793
|
+
const availableFieldsSet = new Set(availableFields);
|
|
794
|
+
checkObjectFields(objNode, documentType, availableFieldsSet, schemaFields, context);
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
// src/rules/very-large-query.ts
|
|
801
|
+
var TEN_KB = 10 * 1024;
|
|
802
|
+
var veryLargeQuery = {
|
|
803
|
+
id: "very-large-query",
|
|
804
|
+
name: "Very Large Query",
|
|
805
|
+
description: "This query is very large and may execute slowly.",
|
|
806
|
+
severity: "warning",
|
|
807
|
+
category: "performance",
|
|
808
|
+
check(_ast, context) {
|
|
809
|
+
if (context.queryLength > TEN_KB) {
|
|
810
|
+
context.report({
|
|
811
|
+
message: `This query is ${formatSize2(context.queryLength)}, which is very large and may execute slowly. It may be deprioritized by the server.`,
|
|
812
|
+
severity: "warning",
|
|
813
|
+
help: "Consider breaking the query into smaller parts or simplifying projections."
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
function formatSize2(bytes) {
|
|
819
|
+
if (bytes >= 1024 * 1024) {
|
|
820
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
821
|
+
}
|
|
822
|
+
if (bytes >= 1024) {
|
|
823
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
824
|
+
}
|
|
825
|
+
return `${bytes} bytes`;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// src/rules/index.ts
|
|
829
|
+
var rules = [
|
|
830
|
+
// Performance - query size
|
|
831
|
+
veryLargeQuery,
|
|
832
|
+
extremelyLargeQuery,
|
|
833
|
+
// Performance - joins
|
|
834
|
+
joinInFilter,
|
|
835
|
+
joinToGetId,
|
|
836
|
+
manyJoins,
|
|
837
|
+
repeatedDereference,
|
|
838
|
+
// Performance - pagination
|
|
839
|
+
deepPagination,
|
|
840
|
+
deepPaginationParam,
|
|
841
|
+
largePages,
|
|
842
|
+
// Performance - filtering
|
|
843
|
+
computedValueInFilter,
|
|
844
|
+
nonLiteralComparison,
|
|
845
|
+
// Performance - ordering
|
|
846
|
+
orderOnExpr,
|
|
847
|
+
// Performance - aggregation
|
|
848
|
+
countInCorrelatedSubquery,
|
|
849
|
+
// Correctness
|
|
850
|
+
matchOnId,
|
|
851
|
+
// Schema-aware correctness (requires schema to run)
|
|
852
|
+
invalidTypeFilter,
|
|
853
|
+
unknownField
|
|
854
|
+
];
|
|
855
|
+
var rulesById = Object.fromEntries(
|
|
856
|
+
rules.map((rule) => [rule.id, rule])
|
|
857
|
+
);
|
|
858
|
+
|
|
859
|
+
// src/wasm-linter.ts
|
|
860
|
+
var wasmAvailable = false;
|
|
861
|
+
var wasmInitialized = false;
|
|
862
|
+
var wasmInitPromise = null;
|
|
863
|
+
var wasmModule = null;
|
|
864
|
+
var WASM_RULES = /* @__PURE__ */ new Set([
|
|
865
|
+
"join-in-filter",
|
|
866
|
+
"join-to-get-id",
|
|
867
|
+
"computed-value-in-filter",
|
|
868
|
+
"match-on-id",
|
|
869
|
+
"order-on-expr",
|
|
870
|
+
"deep-pagination",
|
|
871
|
+
"large-pages",
|
|
872
|
+
"non-literal-comparison",
|
|
873
|
+
"repeated-dereference",
|
|
874
|
+
"count-in-correlated-subquery",
|
|
875
|
+
"very-large-query",
|
|
876
|
+
"extremely-large-query",
|
|
877
|
+
"many-joins"
|
|
878
|
+
]);
|
|
879
|
+
function isWasmRule(ruleId) {
|
|
880
|
+
return WASM_RULES.has(ruleId);
|
|
881
|
+
}
|
|
882
|
+
async function initWasmLinter() {
|
|
883
|
+
if (wasmInitialized) {
|
|
884
|
+
return wasmAvailable;
|
|
885
|
+
}
|
|
886
|
+
if (wasmInitPromise) {
|
|
887
|
+
return wasmInitPromise;
|
|
888
|
+
}
|
|
889
|
+
wasmInitPromise = doInit();
|
|
890
|
+
return wasmInitPromise;
|
|
891
|
+
}
|
|
892
|
+
async function doInit() {
|
|
893
|
+
try {
|
|
894
|
+
wasmModule = await import("@sanity/groq-wasm");
|
|
895
|
+
await wasmModule.initWasm();
|
|
896
|
+
wasmAvailable = true;
|
|
897
|
+
wasmInitialized = true;
|
|
898
|
+
return true;
|
|
899
|
+
} catch {
|
|
900
|
+
wasmAvailable = false;
|
|
901
|
+
wasmInitialized = true;
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function isWasmAvailable() {
|
|
906
|
+
return wasmAvailable;
|
|
907
|
+
}
|
|
908
|
+
function lintWithWasm(query, enabledRules) {
|
|
909
|
+
if (!wasmAvailable || !wasmModule) {
|
|
910
|
+
throw new Error("WASM linter not initialized. Call initWasmLinter() first.");
|
|
911
|
+
}
|
|
912
|
+
const wasmFindings = wasmModule.lint(query);
|
|
913
|
+
if (enabledRules) {
|
|
914
|
+
return wasmFindings.filter((f) => enabledRules.has(f.ruleId));
|
|
915
|
+
}
|
|
916
|
+
return wasmFindings;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// src/linter.ts
|
|
920
|
+
async function initLinter() {
|
|
921
|
+
return initWasmLinter();
|
|
922
|
+
}
|
|
923
|
+
function lint(query, options) {
|
|
924
|
+
const { config, schema, forceTs = false } = options ?? {};
|
|
925
|
+
const findings = [];
|
|
926
|
+
if (!query.trim()) {
|
|
927
|
+
return { query, findings };
|
|
928
|
+
}
|
|
929
|
+
const enabledRules = getEnabledRules(config, schema);
|
|
930
|
+
const wasmRuleIds = /* @__PURE__ */ new Set();
|
|
931
|
+
const tsRules = [];
|
|
932
|
+
for (const rule of enabledRules) {
|
|
933
|
+
if (!forceTs && isWasmAvailable() && isWasmRule(rule.id)) {
|
|
934
|
+
wasmRuleIds.add(rule.id);
|
|
935
|
+
} else {
|
|
936
|
+
tsRules.push(rule);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const wasmFindings = [];
|
|
940
|
+
if (wasmRuleIds.size > 0 && isWasmAvailable()) {
|
|
941
|
+
try {
|
|
942
|
+
const wf = lintWithWasm(query, wasmRuleIds);
|
|
943
|
+
wasmFindings.push(...wf);
|
|
944
|
+
} catch {
|
|
945
|
+
for (const ruleId of wasmRuleIds) {
|
|
946
|
+
const rule = enabledRules.find((r) => r.id === ruleId);
|
|
947
|
+
if (rule) {
|
|
948
|
+
tsRules.push(rule);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
wasmRuleIds.clear();
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
let ast;
|
|
955
|
+
if (tsRules.length > 0) {
|
|
956
|
+
try {
|
|
957
|
+
ast = parse(query);
|
|
958
|
+
} catch (error) {
|
|
959
|
+
return {
|
|
960
|
+
query,
|
|
961
|
+
findings: wasmFindings,
|
|
962
|
+
// Return any WASM findings we got
|
|
963
|
+
parseError: error instanceof Error ? error.message : "Unknown parse error"
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
const firedRules = /* @__PURE__ */ new Set();
|
|
968
|
+
for (const f of wasmFindings) {
|
|
969
|
+
firedRules.add(f.ruleId);
|
|
970
|
+
}
|
|
971
|
+
const tsFindings = [];
|
|
972
|
+
if (ast) {
|
|
973
|
+
for (const rule of tsRules) {
|
|
974
|
+
const ruleFindings = [];
|
|
975
|
+
const context = {
|
|
976
|
+
query,
|
|
977
|
+
queryLength: query.length,
|
|
978
|
+
...schema && { schema },
|
|
979
|
+
report: (finding) => {
|
|
980
|
+
ruleFindings.push({
|
|
981
|
+
...finding,
|
|
982
|
+
ruleId: rule.id,
|
|
983
|
+
severity: finding.severity ?? rule.severity
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
rule.check(ast, context);
|
|
988
|
+
if (ruleFindings.length > 0) {
|
|
989
|
+
firedRules.add(rule.id);
|
|
990
|
+
tsFindings.push(...ruleFindings);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
const allFindings = [...wasmFindings, ...tsFindings];
|
|
995
|
+
for (const finding of allFindings) {
|
|
996
|
+
const rule = enabledRules.find((r) => r.id === finding.ruleId);
|
|
997
|
+
if (rule?.supersedes) {
|
|
998
|
+
const isSuperseded = enabledRules.some(
|
|
999
|
+
(r) => r.supersedes?.includes(finding.ruleId) && firedRules.has(r.id)
|
|
1000
|
+
);
|
|
1001
|
+
if (!isSuperseded) {
|
|
1002
|
+
findings.push(finding);
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
findings.push(finding);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return { query, findings };
|
|
1009
|
+
}
|
|
1010
|
+
function getEnabledRules(config, schema) {
|
|
1011
|
+
let rules2 = rules;
|
|
1012
|
+
if (!schema) {
|
|
1013
|
+
rules2 = rules2.filter((rule) => !rule.requiresSchema);
|
|
1014
|
+
}
|
|
1015
|
+
if (!config?.rules) {
|
|
1016
|
+
return rules2;
|
|
1017
|
+
}
|
|
1018
|
+
return rules2.filter((rule) => {
|
|
1019
|
+
const ruleConfig = config.rules?.[rule.id];
|
|
1020
|
+
if (ruleConfig === false) {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
if (typeof ruleConfig === "object" && ruleConfig.enabled === false) {
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
return true;
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// src/cli.ts
|
|
11
1031
|
import { formatFindings, summarizeFindings } from "@sanity/lint-core";
|
|
12
1032
|
var require2 = createRequire(import.meta.url);
|
|
13
1033
|
var { version } = require2("../package.json");
|
|
@@ -125,8 +1145,8 @@ async function expandGlobs(patterns) {
|
|
|
125
1145
|
}
|
|
126
1146
|
async function main() {
|
|
127
1147
|
const options = parseArgs(process.argv.slice(2));
|
|
128
|
-
const
|
|
129
|
-
if (
|
|
1148
|
+
const wasmAvailable2 = await initLinter();
|
|
1149
|
+
if (wasmAvailable2) {
|
|
130
1150
|
}
|
|
131
1151
|
const schema = options.schema ? loadSchema(options.schema) : void 0;
|
|
132
1152
|
if (options.schema && schema) {
|