Skip to content

Commit 9bc84e6

Browse files
committed
collapse union validation errors and filter out root level annotations
1 parent aa98423 commit 9bc84e6

4 files changed

Lines changed: 507 additions & 1 deletion

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type {ErrorObject} from 'ajv';
2+
3+
/**
4+
* Returns the input errors with composite parent-level errors (oneOf/anyOf/allOf/etc.)
5+
* removed when a more specific child error already exists for the same subtree.
6+
*
7+
* Each error has an "effective leaf path": for keywords that name a specific missing or
8+
* unexpected property (`required`, `additionalProperties`, `unevaluatedProperties`,
9+
* `dependentRequired`, `dependencies`, `propertyNames`) it's the instancePath extended
10+
* with that property name; for all other keywords it's just the instancePath.
11+
*
12+
* An error is dropped when its effective leaf path is a strict ancestor of another
13+
* error's effective leaf path. This keeps real per-property errors (e.g. "required
14+
* property 'name' is missing" at the root) while filtering out composite-keyword
15+
* summaries that point at the enclosing object/array.
16+
*/
17+
export function filterOutAncestorErrors(errors: ErrorObject[]): ErrorObject[] {
18+
const effectivePaths = errors.map(effectiveLeafPath);
19+
return errors.filter((_, i) => {
20+
const descendantPrefix = effectivePaths[i] + '/';
21+
return !effectivePaths.some(
22+
(other, j) => j !== i && other.startsWith(descendantPrefix)
23+
);
24+
});
25+
}
26+
27+
function effectiveLeafPath(error: ErrorObject): string {
28+
const params = (error.params ?? {}) as Record<string, unknown>;
29+
const segment =
30+
(typeof params.missingProperty === 'string' && params.missingProperty) ||
31+
(typeof params.additionalProperty === 'string' && params.additionalProperty) ||
32+
(typeof params.unevaluatedProperty === 'string' && params.unevaluatedProperty) ||
33+
(typeof params.propertyName === 'string' && params.propertyName) ||
34+
undefined;
35+
if (segment === undefined) {
36+
return error.instancePath;
37+
}
38+
return error.instancePath + '/' + escapeJsonPointerSegment(segment);
39+
}
40+
41+
function escapeJsonPointerSegment(segment: string): string {
42+
return segment.replace(/~/g, '~0').replace(/\//g, '~1');
43+
}

meta_configurator/src/data/managedValidation.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {sizeOf} from '@/utility/sizeOf';
99
// Import worker as ESM
1010
import ValidationWorker from '@/workers/validationWorker?worker';
1111
import {removeExternalReferences} from '@/schema/removeExternalReferences.ts';
12+
import {collapseUnionErrors} from '@/schema/collapseUnionErrors';
1213

1314
export class ManagedValidation {
1415
private worker: Worker;
@@ -23,7 +24,7 @@ export class ManagedValidation {
2324
if (!resolver) return;
2425

2526
if (type === 'VALIDATION_COMPLETE') {
26-
resolver(new ValidationResult(result.errors || []));
27+
resolver(new ValidationResult(collapseUnionErrors(result.errors || [])));
2728
} else if (type === 'VALIDATION_ERROR') {
2829
console.error('Validation worker error:', error);
2930
resolver(new ValidationResult([]));
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import {describe, expect, it} from 'vitest';
2+
import type {ErrorObject} from 'ajv';
3+
import {collapseUnionErrors} from '@/schema/collapseUnionErrors';
4+
import {ValidationService} from '@/schema/validationService';
5+
6+
function err(overrides: Partial<ErrorObject>): ErrorObject {
7+
return {
8+
instancePath: '',
9+
schemaPath: '#',
10+
keyword: 'type',
11+
params: {},
12+
message: 'invalid',
13+
...overrides,
14+
} as ErrorObject;
15+
}
16+
17+
describe('collapseUnionErrors', () => {
18+
it('collapses a oneOf cluster into a single enriched error', () => {
19+
const errors = [
20+
err({
21+
instancePath: '/a',
22+
schemaPath: '#/properties/a/oneOf/0/maximum',
23+
keyword: 'maximum',
24+
message: 'must be <= 10',
25+
}),
26+
err({
27+
instancePath: '/a',
28+
schemaPath: '#/properties/a/oneOf/1/type',
29+
keyword: 'type',
30+
message: 'must be string',
31+
}),
32+
err({
33+
instancePath: '/a',
34+
schemaPath: '#/properties/a/oneOf',
35+
keyword: 'oneOf',
36+
message: 'must match exactly one schema in oneOf',
37+
}),
38+
];
39+
40+
const result = collapseUnionErrors(errors);
41+
42+
expect(result).toHaveLength(1);
43+
expect(result[0]!.keyword).toBe('oneOf');
44+
expect(result[0]!.instancePath).toBe('/a');
45+
expect(result[0]!.message).toBe(
46+
[
47+
'does not match any oneOf variant:',
48+
' • variant 1: must be <= 10',
49+
' • variant 2: must be string',
50+
].join('\n')
51+
);
52+
});
53+
54+
it('collapses anyOf clusters the same way', () => {
55+
const errors = [
56+
err({
57+
schemaPath: '#/anyOf/0/type',
58+
keyword: 'type',
59+
message: 'must be number',
60+
}),
61+
err({
62+
schemaPath: '#/anyOf/1/type',
63+
keyword: 'type',
64+
message: 'must be boolean',
65+
}),
66+
err({
67+
schemaPath: '#/anyOf',
68+
keyword: 'anyOf',
69+
message: 'must match a schema in anyOf',
70+
}),
71+
];
72+
73+
const result = collapseUnionErrors(errors);
74+
75+
expect(result).toHaveLength(1);
76+
expect(result[0]!.message).toBe(
77+
[
78+
'does not match any anyOf variant:',
79+
' • variant 1: must be number',
80+
' • variant 2: must be boolean',
81+
].join('\n')
82+
);
83+
});
84+
85+
it('groups multiple sub-errors per branch into one variant line', () => {
86+
const errors = [
87+
err({
88+
schemaPath: '#/oneOf/0/type',
89+
keyword: 'type',
90+
message: 'must be string',
91+
}),
92+
err({
93+
schemaPath: '#/oneOf/0/minLength',
94+
keyword: 'minLength',
95+
message: 'must be at least 3 characters',
96+
}),
97+
err({
98+
schemaPath: '#/oneOf',
99+
keyword: 'oneOf',
100+
message: 'must match exactly one schema in oneOf',
101+
}),
102+
];
103+
104+
const result = collapseUnionErrors(errors);
105+
106+
expect(result).toHaveLength(1);
107+
expect(result[0]!.message).toBe(
108+
[
109+
'does not match any oneOf variant:',
110+
' • variant 1: must be string; must be at least 3 characters',
111+
].join('\n')
112+
);
113+
});
114+
115+
it('handles multiple sibling oneOf clusters independently', () => {
116+
const errors = [
117+
// cluster at /a
118+
err({
119+
instancePath: '/a',
120+
schemaPath: '#/properties/a/oneOf/0/type',
121+
keyword: 'type',
122+
message: 'must be string',
123+
}),
124+
err({
125+
instancePath: '/a',
126+
schemaPath: '#/properties/a/oneOf',
127+
keyword: 'oneOf',
128+
}),
129+
// cluster at /b
130+
err({
131+
instancePath: '/b',
132+
schemaPath: '#/properties/b/oneOf/0/type',
133+
keyword: 'type',
134+
message: 'must be number',
135+
}),
136+
err({
137+
instancePath: '/b',
138+
schemaPath: '#/properties/b/oneOf',
139+
keyword: 'oneOf',
140+
}),
141+
];
142+
143+
const result = collapseUnionErrors(errors);
144+
145+
expect(result).toHaveLength(2);
146+
expect(result[0]!.instancePath).toBe('/a');
147+
expect(result[1]!.instancePath).toBe('/b');
148+
expect(result[0]!.message).toContain('must be string');
149+
expect(result[1]!.message).toContain('must be number');
150+
});
151+
152+
it('preserves the summary unchanged if it has no matching branch sub-errors', () => {
153+
const errors = [
154+
err({
155+
instancePath: '/a',
156+
schemaPath: '#/properties/a/oneOf',
157+
keyword: 'oneOf',
158+
message: 'must match exactly one schema in oneOf',
159+
}),
160+
];
161+
162+
const result = collapseUnionErrors(errors);
163+
164+
expect(result).toEqual(errors);
165+
});
166+
167+
it('passes unrelated errors through untouched', () => {
168+
const errors = [
169+
err({instancePath: '/x', keyword: 'type', schemaPath: '#/properties/x/type'}),
170+
err({instancePath: '/y', keyword: 'maximum', schemaPath: '#/properties/y/maximum'}),
171+
];
172+
173+
const result = collapseUnionErrors(errors);
174+
175+
expect(result).toEqual(errors);
176+
});
177+
178+
it('is idempotent', () => {
179+
const errors = [
180+
err({
181+
instancePath: '/a',
182+
schemaPath: '#/properties/a/oneOf/0/maximum',
183+
keyword: 'maximum',
184+
}),
185+
err({
186+
instancePath: '/a',
187+
schemaPath: '#/properties/a/oneOf/1/type',
188+
keyword: 'type',
189+
}),
190+
err({
191+
instancePath: '/a',
192+
schemaPath: '#/properties/a/oneOf',
193+
keyword: 'oneOf',
194+
}),
195+
];
196+
197+
const once = collapseUnionErrors(errors);
198+
const twice = collapseUnionErrors(once);
199+
200+
expect(twice).toEqual(once);
201+
});
202+
203+
it('returns an empty array unchanged', () => {
204+
expect(collapseUnionErrors([])).toEqual([]);
205+
});
206+
207+
it('collapses nested unions (oneOf in oneOf in anyOf) into a single tree-shaped error', () => {
208+
const service = new ValidationService({
209+
type: 'object',
210+
properties: {
211+
a: {
212+
anyOf: [
213+
{
214+
oneOf: [
215+
{
216+
oneOf: [
217+
{type: 'integer', maximum: 5},
218+
{type: 'string', minLength: 3},
219+
],
220+
},
221+
{type: 'boolean'},
222+
],
223+
},
224+
{type: 'null'},
225+
],
226+
},
227+
},
228+
} as any);
229+
230+
const raw = service.validate({a: 100}).errors;
231+
const collapsed = collapseUnionErrors(raw);
232+
233+
expect(collapsed).toHaveLength(1);
234+
expect(collapsed[0]!.instancePath).toBe('/a');
235+
expect(collapsed[0]!.keyword).toBe('anyOf');
236+
expect(collapsed[0]!.message).toBe(
237+
[
238+
'does not match any anyOf variant:',
239+
' • variant 1: does not match any oneOf variant:',
240+
' • variant 1.1: does not match any oneOf variant:',
241+
' • variant 1.1.1: must be <= 5',
242+
' • variant 1.1.2: must be string',
243+
' • variant 1.2: must be boolean',
244+
' • variant 2: must be null',
245+
].join('\n')
246+
);
247+
});
248+
249+
// integration: run real AJV against the schema/data from the issue discussion
250+
it("produces a single annotation for the user's oneOf example", () => {
251+
const service = new ValidationService({
252+
type: 'object',
253+
properties: {
254+
a: {
255+
oneOf: [
256+
{type: 'integer', maximum: 10},
257+
{type: 'string', maxLength: 4},
258+
],
259+
},
260+
},
261+
} as any);
262+
263+
// ValidationService doesn't currently apply the collapse itself — call it explicitly
264+
const raw = service.validate({a: 345}).errors;
265+
const collapsed = collapseUnionErrors(raw);
266+
267+
expect(collapsed).toHaveLength(1);
268+
expect(collapsed[0]!.instancePath).toBe('/a');
269+
expect(collapsed[0]!.keyword).toBe('oneOf');
270+
expect(collapsed[0]!.message).toBe(
271+
[
272+
'does not match any oneOf variant:',
273+
' • variant 1: must be <= 10',
274+
' • variant 2: must be string',
275+
].join('\n')
276+
);
277+
});
278+
});

0 commit comments

Comments
 (0)