@sanity/lint-core 0.0.1 → 0.0.3

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/index.cjs ADDED
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ formatFindings: () => formatFindings,
24
+ formatFindingsJson: () => formatFindingsJson,
25
+ summarizeFindings: () => summarizeFindings
26
+ });
27
+ module.exports = __toCommonJS(index_exports);
28
+
29
+ // src/reporter.ts
30
+ function formatFindings(query, findings) {
31
+ if (findings.length === 0) {
32
+ return "";
33
+ }
34
+ const lines = query.split("\n");
35
+ const output = [];
36
+ for (const finding of findings) {
37
+ const severityPrefix = getSeverityPrefix(finding.severity);
38
+ output.push(`${severityPrefix}[${finding.ruleId}]: ${finding.message}`);
39
+ if (finding.span) {
40
+ const { line, column } = finding.span.start;
41
+ output.push(` --> query:${line}:${column}`);
42
+ const lineContent = lines[line - 1];
43
+ if (lineContent !== void 0) {
44
+ const lineNum = String(line).padStart(3, " ");
45
+ output.push(` |`);
46
+ output.push(`${lineNum} | ${lineContent}`);
47
+ const caretPadding = " ".repeat(column - 1);
48
+ const caretLength = Math.max(
49
+ 1,
50
+ finding.span.end.line === line ? finding.span.end.column - column : lineContent.length - column + 1
51
+ );
52
+ const caret = "^".repeat(caretLength);
53
+ output.push(` | ${caretPadding}${caret}`);
54
+ }
55
+ output.push(` |`);
56
+ }
57
+ if (finding.help) {
58
+ output.push(` = help: ${finding.help}`);
59
+ }
60
+ output.push("");
61
+ }
62
+ return output.join("\n");
63
+ }
64
+ function formatFindingsJson(findings) {
65
+ return JSON.stringify(findings, null, 2);
66
+ }
67
+ function getSeverityPrefix(severity) {
68
+ switch (severity) {
69
+ case "error":
70
+ return "error";
71
+ case "warning":
72
+ return "warning";
73
+ case "info":
74
+ return "info";
75
+ }
76
+ }
77
+ function summarizeFindings(findings) {
78
+ return {
79
+ total: findings.length,
80
+ errors: findings.filter((f) => f.severity === "error").length,
81
+ warnings: findings.filter((f) => f.severity === "warning").length,
82
+ infos: findings.filter((f) => f.severity === "info").length
83
+ };
84
+ }
85
+ // Annotate the CommonJS export names for ESM import in node:
86
+ 0 && (module.exports = {
87
+ formatFindings,
88
+ formatFindingsJson,
89
+ summarizeFindings
90
+ });
91
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/reporter.ts"],"sourcesContent":["// Types\nexport type {\n Severity,\n Category,\n SourceLocation,\n SourceSpan,\n Suggestion,\n Finding,\n RuleContext,\n Rule,\n RuleConfig,\n LinterConfig,\n} from './types'\n\n// Re-export SchemaType from groq-js for convenience\nexport type { SchemaType } from 'groq-js'\n\n// Reporting utilities\nexport { formatFindings, formatFindingsJson, summarizeFindings } from './reporter'\nexport type { FindingsSummary } from './reporter'\n\n// Note: RuleTester is exported from '@sanity/lint-core/testing' to avoid\n// importing vitest in production code\n","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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACKO,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,27 @@
1
+ import { F as Finding } from './types-CPtvWKni.cjs';
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.cjs';
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 };
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/testing.ts
21
+ var testing_exports = {};
22
+ __export(testing_exports, {
23
+ RuleTester: () => RuleTester,
24
+ assertValidGroq: () => assertValidGroq,
25
+ isValidGroq: () => isValidGroq,
26
+ parseGroq: () => parseGroq
27
+ });
28
+ module.exports = __toCommonJS(testing_exports);
29
+
30
+ // src/rule-tester.ts
31
+ var import_vitest = require("vitest");
32
+ var import_groq_js = require("groq-js");
33
+ function runRule(rule, query) {
34
+ const findings = [];
35
+ let ast;
36
+ try {
37
+ ast = (0, import_groq_js.parse)(query);
38
+ } catch {
39
+ return [];
40
+ }
41
+ const context = {
42
+ query,
43
+ queryLength: query.length,
44
+ report: (finding) => {
45
+ findings.push({
46
+ ...finding,
47
+ ruleId: rule.id
48
+ });
49
+ }
50
+ };
51
+ rule.check(ast, context);
52
+ return findings;
53
+ }
54
+ function assertFindingMatches(finding, expected, ruleId) {
55
+ const expectedRuleId = expected.ruleId ?? ruleId;
56
+ (0, import_vitest.expect)(finding.ruleId).toBe(expectedRuleId);
57
+ if (expected.message !== void 0) {
58
+ if (typeof expected.message === "string") {
59
+ (0, import_vitest.expect)(finding.message).toBe(expected.message);
60
+ } else {
61
+ (0, import_vitest.expect)(finding.message).toMatch(expected.message);
62
+ }
63
+ }
64
+ if (expected.severity !== void 0) {
65
+ (0, import_vitest.expect)(finding.severity).toBe(expected.severity);
66
+ }
67
+ if (expected.line !== void 0 && finding.span) {
68
+ (0, import_vitest.expect)(finding.span.start.line).toBe(expected.line);
69
+ }
70
+ if (expected.column !== void 0 && finding.span) {
71
+ (0, import_vitest.expect)(finding.span.start.column).toBe(expected.column);
72
+ }
73
+ }
74
+ var RuleTester = class {
75
+ /**
76
+ * Run tests for a rule
77
+ * @param ruleName - Name for the test suite
78
+ * @param rule - The rule to test
79
+ * @param tests - Valid and invalid test cases
80
+ */
81
+ run(ruleName, rule, tests) {
82
+ (0, import_vitest.describe)(ruleName, () => {
83
+ (0, import_vitest.describe)("valid", () => {
84
+ for (const test of tests.valid) {
85
+ const testCase = typeof test === "string" ? { code: test } : test;
86
+ const testName = testCase.name ?? this.truncate(testCase.code, 60);
87
+ (0, import_vitest.it)(testName, () => {
88
+ const findings = runRule(rule, testCase.code);
89
+ (0, import_vitest.expect)(findings).toHaveLength(0);
90
+ });
91
+ }
92
+ });
93
+ (0, import_vitest.describe)("invalid", () => {
94
+ for (const test of tests.invalid) {
95
+ const testName = test.name ?? this.truncate(test.code, 60);
96
+ (0, import_vitest.it)(testName, () => {
97
+ const findings = runRule(rule, test.code);
98
+ (0, import_vitest.expect)(findings).toHaveLength(test.errors.length);
99
+ for (let i = 0; i < test.errors.length; i++) {
100
+ const finding = findings[i];
101
+ const expected = test.errors[i];
102
+ if (finding && expected) {
103
+ assertFindingMatches(finding, expected, rule.id);
104
+ }
105
+ }
106
+ });
107
+ }
108
+ });
109
+ });
110
+ }
111
+ /**
112
+ * Truncate a string for display
113
+ */
114
+ truncate(str, maxLength) {
115
+ const oneLine = str.replace(/\s+/g, " ").trim();
116
+ if (oneLine.length <= maxLength) {
117
+ return oneLine;
118
+ }
119
+ return oneLine.slice(0, maxLength - 3) + "...";
120
+ }
121
+ };
122
+
123
+ // src/groq-validator.ts
124
+ var import_groq_js2 = require("groq-js");
125
+ function parseGroq(query) {
126
+ const trimmed = query.trim();
127
+ if (!trimmed) {
128
+ const error = {
129
+ message: "Empty GROQ query",
130
+ query
131
+ };
132
+ throw error;
133
+ }
134
+ try {
135
+ return (0, import_groq_js2.parse)(trimmed);
136
+ } catch (e) {
137
+ const error = {
138
+ message: e instanceof Error ? e.message : String(e),
139
+ query
140
+ };
141
+ const posMatch = error.message.match(/position (\d+)/);
142
+ if (posMatch && posMatch[1]) {
143
+ error.position = parseInt(posMatch[1], 10);
144
+ }
145
+ throw error;
146
+ }
147
+ }
148
+ function isValidGroq(query) {
149
+ try {
150
+ parseGroq(query);
151
+ return true;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+ function assertValidGroq(query, context) {
157
+ try {
158
+ parseGroq(query);
159
+ } catch (e) {
160
+ const error = e;
161
+ let message = `Invalid GROQ syntax: ${error.message}`;
162
+ if (context) {
163
+ message = `${context}: ${message}`;
164
+ }
165
+ if (error.position !== void 0) {
166
+ const lines = error.query.split("\n");
167
+ let charCount = 0;
168
+ let errorCol = 0;
169
+ for (const line of lines) {
170
+ if (charCount + line.length >= error.position) {
171
+ errorCol = error.position - charCount;
172
+ break;
173
+ }
174
+ charCount += line.length + 1;
175
+ }
176
+ message += `
177
+
178
+ Query:
179
+ ${error.query}
180
+ ${" ".repeat(errorCol)}^ position ${error.position}`;
181
+ } else {
182
+ message += `
183
+
184
+ Query:
185
+ ${error.query}`;
186
+ }
187
+ throw new Error(message);
188
+ }
189
+ }
190
+ // Annotate the CommonJS export names for ESM import in node:
191
+ 0 && (module.exports = {
192
+ RuleTester,
193
+ assertValidGroq,
194
+ isValidGroq,
195
+ parseGroq
196
+ });
197
+ //# sourceMappingURL=testing.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/testing.ts","../src/rule-tester.ts","../src/groq-validator.ts"],"sourcesContent":["// Testing utilities - this file imports vitest\n// Import from '@sanity/lint-core/testing' in test files\n\nexport { RuleTester } from './rule-tester'\nexport type { ValidTestCase, InvalidTestCase, ExpectedError, RuleTests } from './rule-tester'\n\n// GROQ validation utilities\n// These use groq-js as the single source of truth for GROQ syntax validation\nexport { assertValidGroq, isValidGroq, parseGroq, type GroqParseError } from './groq-validator'\n","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":["import_groq_js"]}
@@ -0,0 +1,124 @@
1
+ import { d as Rule } from './types-CPtvWKni.cjs';
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,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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/lint-core",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Shared types and utilities for Sanity Lint",
5
5
  "author": "Sanity.io <hello@sanity.io>",
6
6
  "license": "MIT",
@@ -18,14 +18,16 @@
18
18
  "exports": {
19
19
  ".": {
20
20
  "types": "./dist/index.d.ts",
21
- "import": "./dist/index.js"
21
+ "import": "./dist/index.js",
22
+ "require": "./dist/index.cjs"
22
23
  },
23
24
  "./testing": {
24
25
  "types": "./dist/testing.d.ts",
25
- "import": "./dist/testing.js"
26
+ "import": "./dist/testing.js",
27
+ "require": "./dist/testing.cjs"
26
28
  }
27
29
  },
28
- "main": "./dist/index.js",
30
+ "main": "./dist/index.cjs",
29
31
  "types": "./dist/index.d.ts",
30
32
  "files": [
31
33
  "dist"
@@ -38,7 +40,7 @@
38
40
  "typescript": "^5.7.2"
39
41
  },
40
42
  "peerDependencies": {
41
- "vitest": "^2.0.0"
43
+ "vitest": ">=2.0.0"
42
44
  },
43
45
  "peerDependenciesMeta": {
44
46
  "vitest": {