@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 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
@@ -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 };
@@ -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":"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","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
+ }