@sanity/lint-core 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -0
- package/dist/testing.d.ts +124 -0
- package/dist/testing.js +167 -0
- package/dist/testing.js.map +1 -0
- package/dist/types-CPtvWKni.d.ts +112 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present Sanity.io
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# @sanity/lint-core
|
|
2
|
+
|
|
3
|
+
Shared types, utilities, and testing infrastructure for Sanity lint packages.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @sanity/lint-core
|
|
9
|
+
# or
|
|
10
|
+
pnpm add @sanity/lint-core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Types
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import type { Rule, RuleContext, Finding, Severity, LinterConfig } from '@sanity/lint-core'
|
|
19
|
+
|
|
20
|
+
// Define a custom rule
|
|
21
|
+
const myRule: Rule = {
|
|
22
|
+
id: 'my-rule',
|
|
23
|
+
name: 'My Rule',
|
|
24
|
+
description: 'Checks for something',
|
|
25
|
+
severity: 'warning',
|
|
26
|
+
check(ast, context) {
|
|
27
|
+
// Return findings
|
|
28
|
+
return []
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Reporting Utilities
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { formatFindings, formatFindingsJson, summarizeFindings } from '@sanity/lint-core'
|
|
37
|
+
|
|
38
|
+
// Format findings for terminal output
|
|
39
|
+
const output = formatFindings(findings)
|
|
40
|
+
|
|
41
|
+
// Format as JSON for CI
|
|
42
|
+
const json = formatFindingsJson(findings)
|
|
43
|
+
|
|
44
|
+
// Get summary statistics
|
|
45
|
+
const summary = summarizeFindings(findings)
|
|
46
|
+
console.log(`${summary.errorCount} errors, ${summary.warningCount} warnings`)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Testing (Vitest)
|
|
50
|
+
|
|
51
|
+
Import from the `/testing` subpath to use the RuleTester:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { RuleTester } from '@sanity/lint-core/testing'
|
|
55
|
+
import { myRule } from '../my-rule'
|
|
56
|
+
|
|
57
|
+
const tester = new RuleTester()
|
|
58
|
+
|
|
59
|
+
tester.run('my-rule', myRule, {
|
|
60
|
+
valid: ['*[_type == "post"]', '*[_type == "post"]{ title }'],
|
|
61
|
+
invalid: [
|
|
62
|
+
{
|
|
63
|
+
code: '*[badPattern]',
|
|
64
|
+
errors: [{ ruleId: 'my-rule' }],
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### GROQ Validation
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
import { isValidGroq, parseGroq, assertValidGroq } from '@sanity/lint-core/testing'
|
|
74
|
+
|
|
75
|
+
// Check if GROQ is valid
|
|
76
|
+
if (isValidGroq('*[_type == "post"]')) {
|
|
77
|
+
// Valid query
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Parse and get AST
|
|
81
|
+
const ast = parseGroq('*[_type == "post"]')
|
|
82
|
+
|
|
83
|
+
// Assert validity (throws on invalid)
|
|
84
|
+
assertValidGroq('*[_type == "post"]')
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## API Reference
|
|
88
|
+
|
|
89
|
+
### Types
|
|
90
|
+
|
|
91
|
+
| Type | Description |
|
|
92
|
+
| -------------- | ---------------------------------------- |
|
|
93
|
+
| `Rule` | Lint rule interface |
|
|
94
|
+
| `RuleContext` | Context passed to rule check functions |
|
|
95
|
+
| `Finding` | A lint finding (error, warning, or info) |
|
|
96
|
+
| `Severity` | `'error' \| 'warning' \| 'info'` |
|
|
97
|
+
| `LinterConfig` | Configuration for the linter |
|
|
98
|
+
| `SchemaType` | Re-exported from groq-js |
|
|
99
|
+
|
|
100
|
+
### Reporter Functions
|
|
101
|
+
|
|
102
|
+
| Function | Description |
|
|
103
|
+
| ------------------------------ | ----------------------------------- |
|
|
104
|
+
| `formatFindings(findings)` | Format findings for terminal output |
|
|
105
|
+
| `formatFindingsJson(findings)` | Format findings as JSON |
|
|
106
|
+
| `summarizeFindings(findings)` | Get error/warning/info counts |
|
|
107
|
+
|
|
108
|
+
### Testing Utilities
|
|
109
|
+
|
|
110
|
+
| Export | Description |
|
|
111
|
+
| ------------------------ | ------------------------------------------ |
|
|
112
|
+
| `RuleTester` | Test harness for lint rules |
|
|
113
|
+
| `isValidGroq(query)` | Check if GROQ query is syntactically valid |
|
|
114
|
+
| `parseGroq(query)` | Parse GROQ and return AST |
|
|
115
|
+
| `assertValidGroq(query)` | Assert GROQ is valid (throws on error) |
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { F as Finding } from './types-CPtvWKni.js';
|
|
2
|
+
export { C as Category, L as LinterConfig, d as Rule, e as RuleConfig, R as RuleContext, S as Severity, a as SourceLocation, b as SourceSpan, c as Suggestion } from './types-CPtvWKni.js';
|
|
3
|
+
export { SchemaType } from 'groq-js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format findings as a human-readable string
|
|
7
|
+
*/
|
|
8
|
+
declare function formatFindings(query: string, findings: Finding[]): string;
|
|
9
|
+
/**
|
|
10
|
+
* Format findings as JSON
|
|
11
|
+
*/
|
|
12
|
+
declare function formatFindingsJson(findings: Finding[]): string;
|
|
13
|
+
/**
|
|
14
|
+
* Summary of findings by severity
|
|
15
|
+
*/
|
|
16
|
+
interface FindingsSummary {
|
|
17
|
+
total: number;
|
|
18
|
+
errors: number;
|
|
19
|
+
warnings: number;
|
|
20
|
+
infos: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Get a summary of findings
|
|
24
|
+
*/
|
|
25
|
+
declare function summarizeFindings(findings: Finding[]): FindingsSummary;
|
|
26
|
+
|
|
27
|
+
export { Finding, type FindingsSummary, formatFindings, formatFindingsJson, summarizeFindings };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// src/reporter.ts
|
|
2
|
+
function formatFindings(query, findings) {
|
|
3
|
+
if (findings.length === 0) {
|
|
4
|
+
return "";
|
|
5
|
+
}
|
|
6
|
+
const lines = query.split("\n");
|
|
7
|
+
const output = [];
|
|
8
|
+
for (const finding of findings) {
|
|
9
|
+
const severityPrefix = getSeverityPrefix(finding.severity);
|
|
10
|
+
output.push(`${severityPrefix}[${finding.ruleId}]: ${finding.message}`);
|
|
11
|
+
if (finding.span) {
|
|
12
|
+
const { line, column } = finding.span.start;
|
|
13
|
+
output.push(` --> query:${line}:${column}`);
|
|
14
|
+
const lineContent = lines[line - 1];
|
|
15
|
+
if (lineContent !== void 0) {
|
|
16
|
+
const lineNum = String(line).padStart(3, " ");
|
|
17
|
+
output.push(` |`);
|
|
18
|
+
output.push(`${lineNum} | ${lineContent}`);
|
|
19
|
+
const caretPadding = " ".repeat(column - 1);
|
|
20
|
+
const caretLength = Math.max(
|
|
21
|
+
1,
|
|
22
|
+
finding.span.end.line === line ? finding.span.end.column - column : lineContent.length - column + 1
|
|
23
|
+
);
|
|
24
|
+
const caret = "^".repeat(caretLength);
|
|
25
|
+
output.push(` | ${caretPadding}${caret}`);
|
|
26
|
+
}
|
|
27
|
+
output.push(` |`);
|
|
28
|
+
}
|
|
29
|
+
if (finding.help) {
|
|
30
|
+
output.push(` = help: ${finding.help}`);
|
|
31
|
+
}
|
|
32
|
+
output.push("");
|
|
33
|
+
}
|
|
34
|
+
return output.join("\n");
|
|
35
|
+
}
|
|
36
|
+
function formatFindingsJson(findings) {
|
|
37
|
+
return JSON.stringify(findings, null, 2);
|
|
38
|
+
}
|
|
39
|
+
function getSeverityPrefix(severity) {
|
|
40
|
+
switch (severity) {
|
|
41
|
+
case "error":
|
|
42
|
+
return "error";
|
|
43
|
+
case "warning":
|
|
44
|
+
return "warning";
|
|
45
|
+
case "info":
|
|
46
|
+
return "info";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function summarizeFindings(findings) {
|
|
50
|
+
return {
|
|
51
|
+
total: findings.length,
|
|
52
|
+
errors: findings.filter((f) => f.severity === "error").length,
|
|
53
|
+
warnings: findings.filter((f) => f.severity === "warning").length,
|
|
54
|
+
infos: findings.filter((f) => f.severity === "info").length
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
formatFindings,
|
|
59
|
+
formatFindingsJson,
|
|
60
|
+
summarizeFindings
|
|
61
|
+
};
|
|
62
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reporter.ts"],"sourcesContent":["import type { Finding } from './types'\n\n/**\n * Format findings as a human-readable string\n */\nexport function formatFindings(query: string, findings: Finding[]): string {\n if (findings.length === 0) {\n return ''\n }\n\n const lines = query.split('\\n')\n const output: string[] = []\n\n for (const finding of findings) {\n const severityPrefix = getSeverityPrefix(finding.severity)\n\n output.push(`${severityPrefix}[${finding.ruleId}]: ${finding.message}`)\n\n if (finding.span) {\n const { line, column } = finding.span.start\n output.push(` --> query:${line}:${column}`)\n\n // Show the offending line with context\n const lineContent = lines[line - 1]\n if (lineContent !== undefined) {\n const lineNum = String(line).padStart(3, ' ')\n output.push(` |`)\n output.push(`${lineNum} | ${lineContent}`)\n\n // Add caret pointing to the issue\n const caretPadding = ' '.repeat(column - 1)\n const caretLength = Math.max(\n 1,\n finding.span.end.line === line\n ? finding.span.end.column - column\n : lineContent.length - column + 1\n )\n const caret = '^'.repeat(caretLength)\n output.push(` | ${caretPadding}${caret}`)\n }\n output.push(` |`)\n }\n\n if (finding.help) {\n output.push(` = help: ${finding.help}`)\n }\n\n output.push('')\n }\n\n return output.join('\\n')\n}\n\n/**\n * Format findings as JSON\n */\nexport function formatFindingsJson(findings: Finding[]): string {\n return JSON.stringify(findings, null, 2)\n}\n\n/**\n * Get severity prefix for display\n */\nfunction getSeverityPrefix(severity: Finding['severity']): string {\n switch (severity) {\n case 'error':\n return 'error'\n case 'warning':\n return 'warning'\n case 'info':\n return 'info'\n }\n}\n\n/**\n * Summary of findings by severity\n */\nexport interface FindingsSummary {\n total: number\n errors: number\n warnings: number\n infos: number\n}\n\n/**\n * Get a summary of findings\n */\nexport function summarizeFindings(findings: Finding[]): FindingsSummary {\n return {\n total: findings.length,\n errors: findings.filter((f) => f.severity === 'error').length,\n warnings: findings.filter((f) => f.severity === 'warning').length,\n infos: findings.filter((f) => f.severity === 'info').length,\n }\n}\n"],"mappings":";AAKO,SAAS,eAAe,OAAe,UAA6B;AACzE,MAAI,SAAS,WAAW,GAAG;AACzB,WAAO;AAAA,EACT;AAEA,QAAM,QAAQ,MAAM,MAAM,IAAI;AAC9B,QAAM,SAAmB,CAAC;AAE1B,aAAW,WAAW,UAAU;AAC9B,UAAM,iBAAiB,kBAAkB,QAAQ,QAAQ;AAEzD,WAAO,KAAK,GAAG,cAAc,IAAI,QAAQ,MAAM,MAAM,QAAQ,OAAO,EAAE;AAEtE,QAAI,QAAQ,MAAM;AAChB,YAAM,EAAE,MAAM,OAAO,IAAI,QAAQ,KAAK;AACtC,aAAO,KAAK,eAAe,IAAI,IAAI,MAAM,EAAE;AAG3C,YAAM,cAAc,MAAM,OAAO,CAAC;AAClC,UAAI,gBAAgB,QAAW;AAC7B,cAAM,UAAU,OAAO,IAAI,EAAE,SAAS,GAAG,GAAG;AAC5C,eAAO,KAAK,MAAM;AAClB,eAAO,KAAK,GAAG,OAAO,MAAM,WAAW,EAAE;AAGzC,cAAM,eAAe,IAAI,OAAO,SAAS,CAAC;AAC1C,cAAM,cAAc,KAAK;AAAA,UACvB;AAAA,UACA,QAAQ,KAAK,IAAI,SAAS,OACtB,QAAQ,KAAK,IAAI,SAAS,SAC1B,YAAY,SAAS,SAAS;AAAA,QACpC;AACA,cAAM,QAAQ,IAAI,OAAO,WAAW;AACpC,eAAO,KAAK,QAAQ,YAAY,GAAG,KAAK,EAAE;AAAA,MAC5C;AACA,aAAO,KAAK,MAAM;AAAA,IACpB;AAEA,QAAI,QAAQ,MAAM;AAChB,aAAO,KAAK,aAAa,QAAQ,IAAI,EAAE;AAAA,IACzC;AAEA,WAAO,KAAK,EAAE;AAAA,EAChB;AAEA,SAAO,OAAO,KAAK,IAAI;AACzB;AAKO,SAAS,mBAAmB,UAA6B;AAC9D,SAAO,KAAK,UAAU,UAAU,MAAM,CAAC;AACzC;AAKA,SAAS,kBAAkB,UAAuC;AAChE,UAAQ,UAAU;AAAA,IAChB,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,EACX;AACF;AAeO,SAAS,kBAAkB,UAAsC;AACtE,SAAO;AAAA,IACL,OAAO,SAAS;AAAA,IAChB,QAAQ,SAAS,OAAO,CAAC,MAAM,EAAE,aAAa,OAAO,EAAE;AAAA,IACvD,UAAU,SAAS,OAAO,CAAC,MAAM,EAAE,aAAa,SAAS,EAAE;AAAA,IAC3D,OAAO,SAAS,OAAO,CAAC,MAAM,EAAE,aAAa,MAAM,EAAE;AAAA,EACvD;AACF;","names":[]}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { d as Rule } from './types-CPtvWKni.js';
|
|
2
|
+
import { ExprNode } from 'groq-js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A valid test case - query that should produce no findings
|
|
6
|
+
*/
|
|
7
|
+
interface ValidTestCase {
|
|
8
|
+
/** The GROQ query to test */
|
|
9
|
+
code: string;
|
|
10
|
+
/** Optional name for the test */
|
|
11
|
+
name?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Expected error in an invalid test case
|
|
15
|
+
*/
|
|
16
|
+
interface ExpectedError {
|
|
17
|
+
/** Expected rule ID (defaults to the rule being tested) */
|
|
18
|
+
ruleId?: string;
|
|
19
|
+
/** Expected message (string for exact match, RegExp for pattern) */
|
|
20
|
+
message?: string | RegExp;
|
|
21
|
+
/** Expected severity */
|
|
22
|
+
severity?: 'error' | 'warning' | 'info';
|
|
23
|
+
/** Expected line number (1-based) */
|
|
24
|
+
line?: number;
|
|
25
|
+
/** Expected column number (1-based) */
|
|
26
|
+
column?: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* An invalid test case - query that should produce findings
|
|
30
|
+
*/
|
|
31
|
+
interface InvalidTestCase {
|
|
32
|
+
/** The GROQ query to test */
|
|
33
|
+
code: string;
|
|
34
|
+
/** Optional name for the test */
|
|
35
|
+
name?: string;
|
|
36
|
+
/** Expected errors */
|
|
37
|
+
errors: ExpectedError[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Test suite for a rule
|
|
41
|
+
*/
|
|
42
|
+
interface RuleTests {
|
|
43
|
+
/** Queries that should produce no findings */
|
|
44
|
+
valid: (string | ValidTestCase)[];
|
|
45
|
+
/** Queries that should produce findings */
|
|
46
|
+
invalid: InvalidTestCase[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Test utility for lint rules, inspired by ESLint's RuleTester
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```typescript
|
|
53
|
+
* import { RuleTester } from '@sanity/lint-core'
|
|
54
|
+
* import { joinInFilter } from '../join-in-filter'
|
|
55
|
+
*
|
|
56
|
+
* const tester = new RuleTester()
|
|
57
|
+
*
|
|
58
|
+
* tester.run('join-in-filter', joinInFilter, {
|
|
59
|
+
* valid: [
|
|
60
|
+
* '*[_type == "post"]',
|
|
61
|
+
* '*[_type == "post"]{ author-> }',
|
|
62
|
+
* ],
|
|
63
|
+
* invalid: [
|
|
64
|
+
* {
|
|
65
|
+
* code: '*[author->name == "Bob"]',
|
|
66
|
+
* errors: [{ ruleId: 'join-in-filter' }]
|
|
67
|
+
* }
|
|
68
|
+
* ]
|
|
69
|
+
* })
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
declare class RuleTester {
|
|
73
|
+
/**
|
|
74
|
+
* Run tests for a rule
|
|
75
|
+
* @param ruleName - Name for the test suite
|
|
76
|
+
* @param rule - The rule to test
|
|
77
|
+
* @param tests - Valid and invalid test cases
|
|
78
|
+
*/
|
|
79
|
+
run(ruleName: string, rule: Rule, tests: RuleTests): void;
|
|
80
|
+
/**
|
|
81
|
+
* Truncate a string for display
|
|
82
|
+
*/
|
|
83
|
+
private truncate;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* GROQ validation utilities using groq-js as the single source of truth.
|
|
88
|
+
*
|
|
89
|
+
* Use these in tests to ensure queries are valid GROQ syntax:
|
|
90
|
+
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* import { assertValidGroq } from '@sanity/lint-core/testing'
|
|
93
|
+
*
|
|
94
|
+
* it('produces valid GROQ', () => {
|
|
95
|
+
* const result = formatGroq(query)
|
|
96
|
+
* assertValidGroq(result) // Throws if invalid
|
|
97
|
+
* })
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
interface GroqParseError {
|
|
102
|
+
message: string;
|
|
103
|
+
position?: number;
|
|
104
|
+
query: string;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Parse a GROQ query string and return the AST.
|
|
108
|
+
* Throws a GroqParseError if the query is invalid.
|
|
109
|
+
*/
|
|
110
|
+
declare function parseGroq(query: string): ExprNode;
|
|
111
|
+
/**
|
|
112
|
+
* Check if a string is valid GROQ syntax.
|
|
113
|
+
* Returns true if valid, false if invalid.
|
|
114
|
+
*/
|
|
115
|
+
declare function isValidGroq(query: string): boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Assert that a string is valid GROQ syntax.
|
|
118
|
+
* Throws an error with helpful context if invalid.
|
|
119
|
+
*
|
|
120
|
+
* Use this in tests to validate formatter output or generated queries.
|
|
121
|
+
*/
|
|
122
|
+
declare function assertValidGroq(query: string, context?: string): void;
|
|
123
|
+
|
|
124
|
+
export { type ExpectedError, type GroqParseError, type InvalidTestCase, RuleTester, type RuleTests, type ValidTestCase, assertValidGroq, isValidGroq, parseGroq };
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// src/rule-tester.ts
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { parse } from "groq-js";
|
|
4
|
+
function runRule(rule, query) {
|
|
5
|
+
const findings = [];
|
|
6
|
+
let ast;
|
|
7
|
+
try {
|
|
8
|
+
ast = parse(query);
|
|
9
|
+
} catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
const context = {
|
|
13
|
+
query,
|
|
14
|
+
queryLength: query.length,
|
|
15
|
+
report: (finding) => {
|
|
16
|
+
findings.push({
|
|
17
|
+
...finding,
|
|
18
|
+
ruleId: rule.id
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
rule.check(ast, context);
|
|
23
|
+
return findings;
|
|
24
|
+
}
|
|
25
|
+
function assertFindingMatches(finding, expected, ruleId) {
|
|
26
|
+
const expectedRuleId = expected.ruleId ?? ruleId;
|
|
27
|
+
expect(finding.ruleId).toBe(expectedRuleId);
|
|
28
|
+
if (expected.message !== void 0) {
|
|
29
|
+
if (typeof expected.message === "string") {
|
|
30
|
+
expect(finding.message).toBe(expected.message);
|
|
31
|
+
} else {
|
|
32
|
+
expect(finding.message).toMatch(expected.message);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (expected.severity !== void 0) {
|
|
36
|
+
expect(finding.severity).toBe(expected.severity);
|
|
37
|
+
}
|
|
38
|
+
if (expected.line !== void 0 && finding.span) {
|
|
39
|
+
expect(finding.span.start.line).toBe(expected.line);
|
|
40
|
+
}
|
|
41
|
+
if (expected.column !== void 0 && finding.span) {
|
|
42
|
+
expect(finding.span.start.column).toBe(expected.column);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
var RuleTester = class {
|
|
46
|
+
/**
|
|
47
|
+
* Run tests for a rule
|
|
48
|
+
* @param ruleName - Name for the test suite
|
|
49
|
+
* @param rule - The rule to test
|
|
50
|
+
* @param tests - Valid and invalid test cases
|
|
51
|
+
*/
|
|
52
|
+
run(ruleName, rule, tests) {
|
|
53
|
+
describe(ruleName, () => {
|
|
54
|
+
describe("valid", () => {
|
|
55
|
+
for (const test of tests.valid) {
|
|
56
|
+
const testCase = typeof test === "string" ? { code: test } : test;
|
|
57
|
+
const testName = testCase.name ?? this.truncate(testCase.code, 60);
|
|
58
|
+
it(testName, () => {
|
|
59
|
+
const findings = runRule(rule, testCase.code);
|
|
60
|
+
expect(findings).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
describe("invalid", () => {
|
|
65
|
+
for (const test of tests.invalid) {
|
|
66
|
+
const testName = test.name ?? this.truncate(test.code, 60);
|
|
67
|
+
it(testName, () => {
|
|
68
|
+
const findings = runRule(rule, test.code);
|
|
69
|
+
expect(findings).toHaveLength(test.errors.length);
|
|
70
|
+
for (let i = 0; i < test.errors.length; i++) {
|
|
71
|
+
const finding = findings[i];
|
|
72
|
+
const expected = test.errors[i];
|
|
73
|
+
if (finding && expected) {
|
|
74
|
+
assertFindingMatches(finding, expected, rule.id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Truncate a string for display
|
|
84
|
+
*/
|
|
85
|
+
truncate(str, maxLength) {
|
|
86
|
+
const oneLine = str.replace(/\s+/g, " ").trim();
|
|
87
|
+
if (oneLine.length <= maxLength) {
|
|
88
|
+
return oneLine;
|
|
89
|
+
}
|
|
90
|
+
return oneLine.slice(0, maxLength - 3) + "...";
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// src/groq-validator.ts
|
|
95
|
+
import { parse as parse2 } from "groq-js";
|
|
96
|
+
function parseGroq(query) {
|
|
97
|
+
const trimmed = query.trim();
|
|
98
|
+
if (!trimmed) {
|
|
99
|
+
const error = {
|
|
100
|
+
message: "Empty GROQ query",
|
|
101
|
+
query
|
|
102
|
+
};
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
return parse2(trimmed);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
const error = {
|
|
109
|
+
message: e instanceof Error ? e.message : String(e),
|
|
110
|
+
query
|
|
111
|
+
};
|
|
112
|
+
const posMatch = error.message.match(/position (\d+)/);
|
|
113
|
+
if (posMatch && posMatch[1]) {
|
|
114
|
+
error.position = parseInt(posMatch[1], 10);
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function isValidGroq(query) {
|
|
120
|
+
try {
|
|
121
|
+
parseGroq(query);
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function assertValidGroq(query, context) {
|
|
128
|
+
try {
|
|
129
|
+
parseGroq(query);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
const error = e;
|
|
132
|
+
let message = `Invalid GROQ syntax: ${error.message}`;
|
|
133
|
+
if (context) {
|
|
134
|
+
message = `${context}: ${message}`;
|
|
135
|
+
}
|
|
136
|
+
if (error.position !== void 0) {
|
|
137
|
+
const lines = error.query.split("\n");
|
|
138
|
+
let charCount = 0;
|
|
139
|
+
let errorCol = 0;
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
if (charCount + line.length >= error.position) {
|
|
142
|
+
errorCol = error.position - charCount;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
charCount += line.length + 1;
|
|
146
|
+
}
|
|
147
|
+
message += `
|
|
148
|
+
|
|
149
|
+
Query:
|
|
150
|
+
${error.query}
|
|
151
|
+
${" ".repeat(errorCol)}^ position ${error.position}`;
|
|
152
|
+
} else {
|
|
153
|
+
message += `
|
|
154
|
+
|
|
155
|
+
Query:
|
|
156
|
+
${error.query}`;
|
|
157
|
+
}
|
|
158
|
+
throw new Error(message);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export {
|
|
162
|
+
RuleTester,
|
|
163
|
+
assertValidGroq,
|
|
164
|
+
isValidGroq,
|
|
165
|
+
parseGroq
|
|
166
|
+
};
|
|
167
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/rule-tester.ts","../src/groq-validator.ts"],"sourcesContent":["import { describe, it, expect } from 'vitest'\nimport { parse } from 'groq-js'\nimport type { Rule, Finding, RuleContext } from './types'\n\n/**\n * A valid test case - query that should produce no findings\n */\nexport interface ValidTestCase {\n /** The GROQ query to test */\n code: string\n /** Optional name for the test */\n name?: string\n}\n\n/**\n * Expected error in an invalid test case\n */\nexport interface ExpectedError {\n /** Expected rule ID (defaults to the rule being tested) */\n ruleId?: string\n /** Expected message (string for exact match, RegExp for pattern) */\n message?: string | RegExp\n /** Expected severity */\n severity?: 'error' | 'warning' | 'info'\n /** Expected line number (1-based) */\n line?: number\n /** Expected column number (1-based) */\n column?: number\n}\n\n/**\n * An invalid test case - query that should produce findings\n */\nexport interface InvalidTestCase {\n /** The GROQ query to test */\n code: string\n /** Optional name for the test */\n name?: string\n /** Expected errors */\n errors: ExpectedError[]\n}\n\n/**\n * Test suite for a rule\n */\nexport interface RuleTests {\n /** Queries that should produce no findings */\n valid: (string | ValidTestCase)[]\n /** Queries that should produce findings */\n invalid: InvalidTestCase[]\n}\n\n/**\n * Run a single rule against a query and collect findings\n */\nfunction runRule(rule: Rule, query: string): Finding[] {\n const findings: Finding[] = []\n\n let ast\n try {\n ast = parse(query)\n } catch {\n // If query doesn't parse, return empty findings\n // (parser errors are separate from lint errors)\n return []\n }\n\n const context: RuleContext = {\n query,\n queryLength: query.length,\n report: (finding) => {\n findings.push({\n ...finding,\n ruleId: rule.id,\n })\n },\n }\n\n rule.check(ast, context)\n\n return findings\n}\n\n/**\n * Assert that a finding matches expected error\n */\nfunction assertFindingMatches(finding: Finding, expected: ExpectedError, ruleId: string): void {\n // Check rule ID\n const expectedRuleId = expected.ruleId ?? ruleId\n expect(finding.ruleId).toBe(expectedRuleId)\n\n // Check message if specified\n if (expected.message !== undefined) {\n if (typeof expected.message === 'string') {\n expect(finding.message).toBe(expected.message)\n } else {\n expect(finding.message).toMatch(expected.message)\n }\n }\n\n // Check severity if specified\n if (expected.severity !== undefined) {\n expect(finding.severity).toBe(expected.severity)\n }\n\n // Check location if specified\n if (expected.line !== undefined && finding.span) {\n expect(finding.span.start.line).toBe(expected.line)\n }\n if (expected.column !== undefined && finding.span) {\n expect(finding.span.start.column).toBe(expected.column)\n }\n}\n\n/**\n * Test utility for lint rules, inspired by ESLint's RuleTester\n *\n * @example\n * ```typescript\n * import { RuleTester } from '@sanity/lint-core'\n * import { joinInFilter } from '../join-in-filter'\n *\n * const tester = new RuleTester()\n *\n * tester.run('join-in-filter', joinInFilter, {\n * valid: [\n * '*[_type == \"post\"]',\n * '*[_type == \"post\"]{ author-> }',\n * ],\n * invalid: [\n * {\n * code: '*[author->name == \"Bob\"]',\n * errors: [{ ruleId: 'join-in-filter' }]\n * }\n * ]\n * })\n * ```\n */\nexport class RuleTester {\n /**\n * Run tests for a rule\n * @param ruleName - Name for the test suite\n * @param rule - The rule to test\n * @param tests - Valid and invalid test cases\n */\n run(ruleName: string, rule: Rule, tests: RuleTests): void {\n describe(ruleName, () => {\n describe('valid', () => {\n for (const test of tests.valid) {\n const testCase = typeof test === 'string' ? { code: test } : test\n const testName = testCase.name ?? this.truncate(testCase.code, 60)\n\n it(testName, () => {\n const findings = runRule(rule, testCase.code)\n expect(findings).toHaveLength(0)\n })\n }\n })\n\n describe('invalid', () => {\n for (const test of tests.invalid) {\n const testName = test.name ?? this.truncate(test.code, 60)\n\n it(testName, () => {\n const findings = runRule(rule, test.code)\n\n // Check we got the expected number of errors\n expect(findings).toHaveLength(test.errors.length)\n\n // Check each error matches\n for (let i = 0; i < test.errors.length; i++) {\n const finding = findings[i]\n const expected = test.errors[i]\n if (finding && expected) {\n assertFindingMatches(finding, expected, rule.id)\n }\n }\n })\n }\n })\n })\n }\n\n /**\n * Truncate a string for display\n */\n private truncate(str: string, maxLength: number): string {\n const oneLine = str.replace(/\\s+/g, ' ').trim()\n if (oneLine.length <= maxLength) {\n return oneLine\n }\n return oneLine.slice(0, maxLength - 3) + '...'\n }\n}\n","/**\n * GROQ validation utilities using groq-js as the single source of truth.\n *\n * Use these in tests to ensure queries are valid GROQ syntax:\n *\n * ```ts\n * import { assertValidGroq } from '@sanity/lint-core/testing'\n *\n * it('produces valid GROQ', () => {\n * const result = formatGroq(query)\n * assertValidGroq(result) // Throws if invalid\n * })\n * ```\n */\n\nimport { parse, type ExprNode } from 'groq-js'\n\nexport interface GroqParseError {\n message: string\n position?: number\n query: string\n}\n\n/**\n * Parse a GROQ query string and return the AST.\n * Throws a GroqParseError if the query is invalid.\n */\nexport function parseGroq(query: string): ExprNode {\n const trimmed = query.trim()\n if (!trimmed) {\n const error: GroqParseError = {\n message: 'Empty GROQ query',\n query,\n }\n throw error\n }\n\n try {\n return parse(trimmed)\n } catch (e) {\n const error: GroqParseError = {\n message: e instanceof Error ? e.message : String(e),\n query,\n }\n // Extract position from groq-js error message if available\n const posMatch = error.message.match(/position (\\d+)/)\n if (posMatch && posMatch[1]) {\n error.position = parseInt(posMatch[1], 10)\n }\n throw error\n }\n}\n\n/**\n * Check if a string is valid GROQ syntax.\n * Returns true if valid, false if invalid.\n */\nexport function isValidGroq(query: string): boolean {\n try {\n parseGroq(query)\n return true\n } catch {\n return false\n }\n}\n\n/**\n * Assert that a string is valid GROQ syntax.\n * Throws an error with helpful context if invalid.\n *\n * Use this in tests to validate formatter output or generated queries.\n */\nexport function assertValidGroq(query: string, context?: string): void {\n try {\n parseGroq(query)\n } catch (e) {\n const error = e as GroqParseError\n let message = `Invalid GROQ syntax: ${error.message}`\n if (context) {\n message = `${context}: ${message}`\n }\n if (error.position !== undefined) {\n // Show the query with a pointer to the error position\n const lines = error.query.split('\\n')\n let charCount = 0\n let errorCol = 0\n\n for (const line of lines) {\n if (charCount + line.length >= error.position) {\n errorCol = error.position - charCount\n break\n }\n charCount += line.length + 1 // +1 for newline\n }\n\n message += `\\n\\nQuery:\\n${error.query}\\n${' '.repeat(errorCol)}^ position ${error.position}`\n } else {\n message += `\\n\\nQuery:\\n${error.query}`\n }\n\n throw new Error(message)\n }\n}\n"],"mappings":";AAAA,SAAS,UAAU,IAAI,cAAc;AACrC,SAAS,aAAa;AAsDtB,SAAS,QAAQ,MAAY,OAA0B;AACrD,QAAM,WAAsB,CAAC;AAE7B,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,KAAK;AAAA,EACnB,QAAQ;AAGN,WAAO,CAAC;AAAA,EACV;AAEA,QAAM,UAAuB;AAAA,IAC3B;AAAA,IACA,aAAa,MAAM;AAAA,IACnB,QAAQ,CAAC,YAAY;AACnB,eAAS,KAAK;AAAA,QACZ,GAAG;AAAA,QACH,QAAQ,KAAK;AAAA,MACf,CAAC;AAAA,IACH;AAAA,EACF;AAEA,OAAK,MAAM,KAAK,OAAO;AAEvB,SAAO;AACT;AAKA,SAAS,qBAAqB,SAAkB,UAAyB,QAAsB;AAE7F,QAAM,iBAAiB,SAAS,UAAU;AAC1C,SAAO,QAAQ,MAAM,EAAE,KAAK,cAAc;AAG1C,MAAI,SAAS,YAAY,QAAW;AAClC,QAAI,OAAO,SAAS,YAAY,UAAU;AACxC,aAAO,QAAQ,OAAO,EAAE,KAAK,SAAS,OAAO;AAAA,IAC/C,OAAO;AACL,aAAO,QAAQ,OAAO,EAAE,QAAQ,SAAS,OAAO;AAAA,IAClD;AAAA,EACF;AAGA,MAAI,SAAS,aAAa,QAAW;AACnC,WAAO,QAAQ,QAAQ,EAAE,KAAK,SAAS,QAAQ;AAAA,EACjD;AAGA,MAAI,SAAS,SAAS,UAAa,QAAQ,MAAM;AAC/C,WAAO,QAAQ,KAAK,MAAM,IAAI,EAAE,KAAK,SAAS,IAAI;AAAA,EACpD;AACA,MAAI,SAAS,WAAW,UAAa,QAAQ,MAAM;AACjD,WAAO,QAAQ,KAAK,MAAM,MAAM,EAAE,KAAK,SAAS,MAAM;AAAA,EACxD;AACF;AA0BO,IAAM,aAAN,MAAiB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOtB,IAAI,UAAkB,MAAY,OAAwB;AACxD,aAAS,UAAU,MAAM;AACvB,eAAS,SAAS,MAAM;AACtB,mBAAW,QAAQ,MAAM,OAAO;AAC9B,gBAAM,WAAW,OAAO,SAAS,WAAW,EAAE,MAAM,KAAK,IAAI;AAC7D,gBAAM,WAAW,SAAS,QAAQ,KAAK,SAAS,SAAS,MAAM,EAAE;AAEjE,aAAG,UAAU,MAAM;AACjB,kBAAM,WAAW,QAAQ,MAAM,SAAS,IAAI;AAC5C,mBAAO,QAAQ,EAAE,aAAa,CAAC;AAAA,UACjC,CAAC;AAAA,QACH;AAAA,MACF,CAAC;AAED,eAAS,WAAW,MAAM;AACxB,mBAAW,QAAQ,MAAM,SAAS;AAChC,gBAAM,WAAW,KAAK,QAAQ,KAAK,SAAS,KAAK,MAAM,EAAE;AAEzD,aAAG,UAAU,MAAM;AACjB,kBAAM,WAAW,QAAQ,MAAM,KAAK,IAAI;AAGxC,mBAAO,QAAQ,EAAE,aAAa,KAAK,OAAO,MAAM;AAGhD,qBAAS,IAAI,GAAG,IAAI,KAAK,OAAO,QAAQ,KAAK;AAC3C,oBAAM,UAAU,SAAS,CAAC;AAC1B,oBAAM,WAAW,KAAK,OAAO,CAAC;AAC9B,kBAAI,WAAW,UAAU;AACvB,qCAAqB,SAAS,UAAU,KAAK,EAAE;AAAA,cACjD;AAAA,YACF;AAAA,UACF,CAAC;AAAA,QACH;AAAA,MACF,CAAC;AAAA,IACH,CAAC;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKQ,SAAS,KAAa,WAA2B;AACvD,UAAM,UAAU,IAAI,QAAQ,QAAQ,GAAG,EAAE,KAAK;AAC9C,QAAI,QAAQ,UAAU,WAAW;AAC/B,aAAO;AAAA,IACT;AACA,WAAO,QAAQ,MAAM,GAAG,YAAY,CAAC,IAAI;AAAA,EAC3C;AACF;;;AClLA,SAAS,SAAAA,cAA4B;AAY9B,SAAS,UAAU,OAAyB;AACjD,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,CAAC,SAAS;AACZ,UAAM,QAAwB;AAAA,MAC5B,SAAS;AAAA,MACT;AAAA,IACF;AACA,UAAM;AAAA,EACR;AAEA,MAAI;AACF,WAAOA,OAAM,OAAO;AAAA,EACtB,SAAS,GAAG;AACV,UAAM,QAAwB;AAAA,MAC5B,SAAS,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC;AAAA,MAClD;AAAA,IACF;AAEA,UAAM,WAAW,MAAM,QAAQ,MAAM,gBAAgB;AACrD,QAAI,YAAY,SAAS,CAAC,GAAG;AAC3B,YAAM,WAAW,SAAS,SAAS,CAAC,GAAG,EAAE;AAAA,IAC3C;AACA,UAAM;AAAA,EACR;AACF;AAMO,SAAS,YAAY,OAAwB;AAClD,MAAI;AACF,cAAU,KAAK;AACf,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAQO,SAAS,gBAAgB,OAAe,SAAwB;AACrE,MAAI;AACF,cAAU,KAAK;AAAA,EACjB,SAAS,GAAG;AACV,UAAM,QAAQ;AACd,QAAI,UAAU,wBAAwB,MAAM,OAAO;AACnD,QAAI,SAAS;AACX,gBAAU,GAAG,OAAO,KAAK,OAAO;AAAA,IAClC;AACA,QAAI,MAAM,aAAa,QAAW;AAEhC,YAAM,QAAQ,MAAM,MAAM,MAAM,IAAI;AACpC,UAAI,YAAY;AAChB,UAAI,WAAW;AAEf,iBAAW,QAAQ,OAAO;AACxB,YAAI,YAAY,KAAK,UAAU,MAAM,UAAU;AAC7C,qBAAW,MAAM,WAAW;AAC5B;AAAA,QACF;AACA,qBAAa,KAAK,SAAS;AAAA,MAC7B;AAEA,iBAAW;AAAA;AAAA;AAAA,EAAe,MAAM,KAAK;AAAA,EAAK,IAAI,OAAO,QAAQ,CAAC,cAAc,MAAM,QAAQ;AAAA,IAC5F,OAAO;AACL,iBAAW;AAAA;AAAA;AAAA,EAAe,MAAM,KAAK;AAAA,IACvC;AAEA,UAAM,IAAI,MAAM,OAAO;AAAA,EACzB;AACF;","names":["parse"]}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { SchemaType, ExprNode } from 'groq-js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Severity levels for lint findings
|
|
5
|
+
*/
|
|
6
|
+
type Severity = 'error' | 'warning' | 'info';
|
|
7
|
+
/**
|
|
8
|
+
* Category of the lint rule
|
|
9
|
+
*/
|
|
10
|
+
type Category = 'performance' | 'correctness' | 'style';
|
|
11
|
+
/**
|
|
12
|
+
* A location in the source query
|
|
13
|
+
*/
|
|
14
|
+
interface SourceLocation {
|
|
15
|
+
/** 1-based line number */
|
|
16
|
+
line: number;
|
|
17
|
+
/** 1-based column number */
|
|
18
|
+
column: number;
|
|
19
|
+
/** 0-based character offset */
|
|
20
|
+
offset: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A span in the source query
|
|
24
|
+
*/
|
|
25
|
+
interface SourceSpan {
|
|
26
|
+
start: SourceLocation;
|
|
27
|
+
end: SourceLocation;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A suggested fix for a finding
|
|
31
|
+
*/
|
|
32
|
+
interface Suggestion {
|
|
33
|
+
/** Description of what the suggestion does */
|
|
34
|
+
description: string;
|
|
35
|
+
/** The replacement text */
|
|
36
|
+
replacement: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A lint finding/diagnostic
|
|
40
|
+
*/
|
|
41
|
+
interface Finding {
|
|
42
|
+
/** Rule ID that produced this finding */
|
|
43
|
+
ruleId: string;
|
|
44
|
+
/** Human-readable message */
|
|
45
|
+
message: string;
|
|
46
|
+
/** Severity level */
|
|
47
|
+
severity: Severity;
|
|
48
|
+
/** Location in source (optional - some rules are query-wide) */
|
|
49
|
+
span?: SourceSpan;
|
|
50
|
+
/** Additional help text */
|
|
51
|
+
help?: string;
|
|
52
|
+
/** Suggested fixes */
|
|
53
|
+
suggestions?: Suggestion[];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Context provided to rules during checking
|
|
57
|
+
*/
|
|
58
|
+
interface RuleContext {
|
|
59
|
+
/** The raw query string */
|
|
60
|
+
query: string;
|
|
61
|
+
/** Length of the query in bytes */
|
|
62
|
+
queryLength: number;
|
|
63
|
+
/** Report a finding */
|
|
64
|
+
report: (finding: Omit<Finding, 'ruleId'>) => void;
|
|
65
|
+
/** Schema for schema-aware rules (optional) */
|
|
66
|
+
schema?: SchemaType;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A lint rule definition
|
|
70
|
+
*/
|
|
71
|
+
interface Rule {
|
|
72
|
+
/** Unique identifier (kebab-case) */
|
|
73
|
+
id: string;
|
|
74
|
+
/** Human-readable name */
|
|
75
|
+
name: string;
|
|
76
|
+
/** Description of what the rule checks */
|
|
77
|
+
description: string;
|
|
78
|
+
/** Default severity */
|
|
79
|
+
severity: Severity;
|
|
80
|
+
/** Rule category */
|
|
81
|
+
category: Category;
|
|
82
|
+
/** Rule IDs that this rule supersedes */
|
|
83
|
+
supersedes?: string[];
|
|
84
|
+
/** Whether this rule requires a schema to function */
|
|
85
|
+
requiresSchema?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Check the AST for violations
|
|
88
|
+
* @param ast - The parsed GROQ AST
|
|
89
|
+
* @param context - Context with query info and report function
|
|
90
|
+
*/
|
|
91
|
+
check: (ast: ExprNode, context: RuleContext) => void;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Configuration for a rule
|
|
95
|
+
*/
|
|
96
|
+
interface RuleConfig {
|
|
97
|
+
/** Whether the rule is enabled */
|
|
98
|
+
enabled?: boolean;
|
|
99
|
+
/** Override severity */
|
|
100
|
+
severity?: Severity;
|
|
101
|
+
/** Rule-specific options */
|
|
102
|
+
options?: Record<string, unknown>;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Linter configuration
|
|
106
|
+
*/
|
|
107
|
+
interface LinterConfig {
|
|
108
|
+
/** Rule configurations keyed by rule ID */
|
|
109
|
+
rules?: Record<string, RuleConfig | boolean>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type { Category as C, Finding as F, LinterConfig as L, RuleContext as R, Severity as S, SourceLocation as a, SourceSpan as b, Suggestion as c, Rule as d, RuleConfig as e };
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sanity/lint-core",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Shared types and utilities for Sanity Lint",
|
|
5
|
+
"author": "Sanity.io <hello@sanity.io>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/sanity-io/sanity-lint.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"sanity",
|
|
14
|
+
"lint",
|
|
15
|
+
"eslint"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./testing": {
|
|
24
|
+
"types": "./dist/testing.d.ts",
|
|
25
|
+
"import": "./dist/testing.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"groq-js": "^1.14.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"tsup": "^8.3.5",
|
|
38
|
+
"typescript": "^5.7.2"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"vitest": "^2.0.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"vitest": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup",
|
|
53
|
+
"dev": "tsup --watch",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"clean": "rm -rf dist"
|
|
56
|
+
}
|
|
57
|
+
}
|