Skip to content

Commit 490b81d

Browse files
authored
fix: validator merging edge cases (#239)
* fix: validator merging edge cases * fix: validate order
1 parent c5ddb60 commit 490b81d

File tree

2 files changed

+214
-26
lines changed

2 files changed

+214
-26
lines changed

src/rules/no-invalid.ts

Lines changed: 87 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,20 @@ function reportCannotResolvedObject(context: RuleContext) {
8787
});
8888
}
8989

90+
type SchemaKind = "$schema" | "catalog" | "options";
91+
const SCHEMA_KINDS: SchemaKind[] = ["$schema", "options", "catalog"];
92+
9093
/** Get mergeSchemas option */
9194
function parseMergeSchemasOption(
9295
option: boolean | string[] | undefined,
93-
): string[] | null {
94-
return option === true ? ["$schema", "catalog", "options"] : option || null;
96+
): SchemaKind[] | null {
97+
return option === true
98+
? SCHEMA_KINDS
99+
: Array.isArray(option)
100+
? [...(option as SchemaKind[])].sort(
101+
(a, b) => SCHEMA_KINDS.indexOf(a) - SCHEMA_KINDS.indexOf(b),
102+
)
103+
: null;
95104
}
96105

97106
export default createRule("no-invalid", {
@@ -137,6 +146,8 @@ export default createRule("no-invalid", {
137146
type: "string",
138147
enum: ["$schema", "catalog", "options"],
139148
},
149+
minItems: 2,
150+
uniqueItems: true,
140151
},
141152
],
142153
},
@@ -156,6 +167,9 @@ export default createRule("no-invalid", {
156167
: filename;
157168

158169
const validator = createValidator(context, relativeFilename);
170+
if (!validator) {
171+
return {};
172+
}
159173

160174
let existsExports = false;
161175
const sourceCode = context.getSourceCode();
@@ -167,7 +181,7 @@ export default createRule("no-invalid", {
167181
data: unknown,
168182
resolveLoc: (error: ValidateError) => JSONAST.SourceLocation | null,
169183
) {
170-
const errors = validator(data);
184+
const errors = validator!(data);
171185
for (const error of errors) {
172186
const loc = resolveLoc(error);
173187

@@ -463,31 +477,80 @@ export default createRule("no-invalid", {
463477
context.options[0]?.mergeSchemas,
464478
);
465479

466-
const validators: Validator[] = [];
467-
if (mergeSchemas) {
468-
if (mergeSchemas.includes("$schema")) {
469-
validators.push(...(get$SchemaValidators(context) || []));
480+
const validatorsCtx = createValidatorsContext(context, filename);
481+
if (mergeSchemas && mergeSchemas.some((kind) => validatorsCtx[kind])) {
482+
const validators: Validator[] = [];
483+
for (const kind of mergeSchemas) {
484+
const v = validatorsCtx[kind];
485+
if (v) validators.push(...v);
470486
}
471-
if (mergeSchemas.includes("options")) {
472-
validators.push(...(getOptionsValidators(context, filename) || []));
473-
}
474-
if (mergeSchemas.includes("catalog")) {
475-
validators.push(...(getCatalogValidators(context, filename) || []));
487+
return margeValidators(validators);
488+
}
489+
490+
const validators =
491+
validatorsCtx.$schema || validatorsCtx.options || validatorsCtx.catalog;
492+
if (!validators) {
493+
return null;
494+
}
495+
return margeValidators(validators);
496+
497+
/** Marge validators */
498+
function margeValidators(validators: Validator[]) {
499+
return (data: unknown) =>
500+
validators.reduce(
501+
(errors, validator) => [...errors, ...validator(data)],
502+
[] as ValidateError[],
503+
);
504+
}
505+
}
506+
507+
/** Creates validators context */
508+
function createValidatorsContext(context: RuleContext, filename: string) {
509+
type Cache = { validators: Validator[] | null };
510+
let $schema: Cache | null = null;
511+
let options: Cache | null = null;
512+
let catalog: Cache | null = null;
513+
514+
/**
515+
* Get a validator. Returns the value of the cache if there is one.
516+
* If there is no cache, cache and return the value obtained from the supplier function
517+
*/
518+
function get(
519+
cache: Cache | null,
520+
setCache: (c: Cache) => void,
521+
supplier: () => Validator[] | null,
522+
) {
523+
if (cache) {
524+
return cache.validators;
476525
}
477-
} else {
478-
validators.push(
479-
...(get$SchemaValidators(context) ||
480-
getOptionsValidators(context, filename) ||
481-
getCatalogValidators(context, filename) ||
482-
[]),
483-
);
526+
const v = supplier();
527+
setCache({ validators: v });
528+
return v;
484529
}
485530

486-
return (data: unknown) =>
487-
validators.reduce(
488-
(errors, validator) => [...errors, ...validator(data)],
489-
[] as ValidateError[],
490-
);
531+
return {
532+
get $schema() {
533+
return get(
534+
$schema,
535+
(c) => ($schema = c),
536+
() => get$SchemaValidators(context),
537+
);
538+
},
539+
get options() {
540+
return get(
541+
options,
542+
(c) => (options = c),
543+
() => getOptionsValidators(context, filename),
544+
);
545+
},
546+
get catalog() {
547+
return get(
548+
catalog,
549+
(c) => (catalog = c),
550+
() => getCatalogValidators(context, filename),
551+
);
552+
},
553+
};
491554
}
492555
},
493556
});

tests/src/rules/no-invalid.ts

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ tester.run(
7979
},
8080
{
8181
filename: path.join(__dirname, ".eslintrc.json"),
82-
code: '{ "extends": [ 42 ], "$schema": "https://json.schemastore.org/eslintrc" }',
82+
code: '{ "extends": [ 98 ], "$schema": "https://json.schemastore.org/eslintrc" }',
8383
options: [
8484
{
8585
schemas: [
@@ -108,7 +108,7 @@ tester.run(
108108
},
109109
{
110110
filename: path.join(__dirname, "version.json"),
111-
code: '{ "extends": [ 42 ], "$schema": "https://json.schemastore.org/eslintrc" }',
111+
code: '{ "extends": [ 99 ], "$schema": "https://json.schemastore.org/eslintrc" }',
112112
options: [
113113
{
114114
schemas: [
@@ -138,6 +138,131 @@ tester.run(
138138
'"extends[0]" must be string.',
139139
],
140140
},
141+
{
142+
filename: path.join(__dirname, "version.json"),
143+
code: '{ "extends": [ 100 ], "$schema": "https://json.schemastore.org/eslintrc" }',
144+
options: [
145+
{
146+
schemas: [
147+
{
148+
fileMatch: ["tests/src/rules/version.json"],
149+
schema: {
150+
properties: {
151+
foo: {
152+
type: "number",
153+
},
154+
},
155+
required: ["foo"],
156+
},
157+
},
158+
],
159+
mergeSchemas: ["catalog", "options"],
160+
useSchemastoreCatalog: true,
161+
},
162+
],
163+
errors: [
164+
"Root must have required property 'foo'.",
165+
"Root must have required property 'version'.",
166+
"Root must have required property 'inherit'.",
167+
"Root must match a schema in anyOf.",
168+
],
169+
},
170+
{
171+
filename: path.join(__dirname, "version.json"),
172+
code: '{ "extends": [ 101 ], "$schema": "https://json.schemastore.org/eslintrc" }',
173+
options: [
174+
{
175+
schemas: [
176+
{
177+
fileMatch: ["tests/src/rules/version.json"],
178+
schema: {
179+
properties: {
180+
foo: {
181+
type: "number",
182+
},
183+
},
184+
required: ["foo"],
185+
},
186+
},
187+
],
188+
mergeSchemas: ["$schema", "options"],
189+
useSchemastoreCatalog: true,
190+
},
191+
],
192+
errors: [
193+
"Root must have required property 'foo'.",
194+
'"extends" must be string.',
195+
'"extends" must match exactly one schema in oneOf.',
196+
'"extends[0]" must be string.',
197+
],
198+
},
199+
{
200+
filename: path.join(__dirname, "version.json"),
201+
code: '{ "extends": [ 102 ], "$schema": "https://json.schemastore.org/eslintrc" }',
202+
options: [
203+
{
204+
schemas: [
205+
{
206+
fileMatch: ["tests/src/rules/version.json"],
207+
schema: {
208+
properties: {
209+
foo: {
210+
type: "number",
211+
},
212+
},
213+
required: ["foo"],
214+
},
215+
},
216+
],
217+
mergeSchemas: ["$schema", "catalog"],
218+
useSchemastoreCatalog: false,
219+
},
220+
],
221+
errors: [
222+
'"extends" must be string.',
223+
'"extends" must match exactly one schema in oneOf.',
224+
'"extends[0]" must be string.',
225+
],
226+
},
227+
{
228+
filename: path.join(__dirname, "version.json"),
229+
code: '{ "extends": [ 103 ], "$schema": "https://json.schemastore.org/eslintrc" }',
230+
options: [
231+
{
232+
schemas: [
233+
{
234+
fileMatch: ["tests/src/rules/version.json"],
235+
schema: {
236+
properties: {
237+
foo: {
238+
type: "number",
239+
},
240+
},
241+
required: ["foo"],
242+
},
243+
},
244+
],
245+
mergeSchemas: ["options", "catalog"],
246+
useSchemastoreCatalog: false,
247+
},
248+
],
249+
errors: ["Root must have required property 'foo'."],
250+
},
251+
{
252+
filename: path.join(__dirname, "version.json"),
253+
code: '{ "extends": [ 104 ], "$schema": "https://json.schemastore.org/eslintrc" }',
254+
options: [
255+
{
256+
mergeSchemas: ["options", "catalog"],
257+
useSchemastoreCatalog: false,
258+
},
259+
],
260+
errors: [
261+
'"extends" must be string.',
262+
'"extends" must match exactly one schema in oneOf.',
263+
'"extends[0]" must be string.',
264+
],
265+
},
141266
{
142267
filename: path.join(__dirname, ".prettierrc.toml"),
143268
code: `

0 commit comments

Comments
 (0)