Skip to content

Commit 1ddd272

Browse files
committed
fix(@angular-devkit/core): name the unknown option in a schema validation error
An unknown property in `angular.json` or in a builder's options reported `Data path "" must NOT have additional properties(allowedCommonJsDependencies).`, which names neither the object the property was found in nor what is valid there. It now reads `Unknown option "allowedCommonJsDependencies" at the top level. Valid options are: assets, browser, ...`, and `at "/cli"` where the instance path is not empty. Every other error reads `Option at "/outputPath" must be string.` in place of the `Data path` prefix, keeping the list of allowed values an enum error already appended. Listing the valid options needs ajv's `verbose` option, which is what puts `parentSchema` on an error. That is the schema the property was rejected by, so a `$ref` and a `oneOf` branch each list their own options; a schema that declares no `properties` at all stops the sentence after the location rather than offering an empty list.
1 parent 758192d commit 1ddd272

4 files changed

Lines changed: 121 additions & 27 deletions

File tree

packages/angular_devkit/core/src/json/schema/registry.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -61,19 +61,26 @@ export class SchemaValidationException extends BaseException {
6161
}
6262

6363
const messages = errors.map((err) => {
64-
let message = `Data path ${JSON.stringify(err.instancePath)} ${err.message}`;
65-
if (err.params) {
66-
switch (err.keyword) {
67-
case 'additionalProperties':
68-
message += `(${err.params.additionalProperty})`;
69-
break;
70-
71-
case 'enum':
72-
message += `. Allowed values are: ${(err.params.allowedValues as string[] | undefined)
73-
?.map((v) => `"${v}"`)
74-
.join(', ')}`;
75-
break;
76-
}
64+
const location = err.instancePath ? `"${err.instancePath}"` : 'the top level';
65+
66+
if (err.keyword === 'additionalProperties') {
67+
const unknown = err.params?.additionalProperty;
68+
// `parentSchema` is the schema that rejected the property, which ajv only attaches when
69+
// the validator was created with `verbose: true`. A schema that declares no `properties`
70+
// of its own, such as one using only `patternProperties`, has no options to offer.
71+
const known = Object.keys(err.parentSchema?.properties ?? {});
72+
73+
return (
74+
`Unknown option "${unknown}" at ${location}.` +
75+
(known.length ? ` Valid options are: ${known.join(', ')}.` : '')
76+
);
77+
}
78+
79+
let message = `Option at ${location} ${err.message}`;
80+
if (err.keyword === 'enum' && err.params) {
81+
message += `. Allowed values are: ${(err.params.allowedValues as string[] | undefined)
82+
?.map((v) => `"${v}"`)
83+
.join(', ')}`;
7784
}
7885

7986
return message + '.';
@@ -106,6 +113,8 @@ export class CoreSchemaRegistry implements SchemaRegistry {
106113
strict: false,
107114
loadSchema: (uri: string) => this._fetch(uri),
108115
passContext: true,
116+
// Needed to list the valid options of the object an unknown option was found in.
117+
verbose: true,
109118
});
110119

111120
ajvAddFormats(this._ajv);

packages/angular_devkit/core/src/json/schema/registry_spec.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
*/
88

99
/* eslint-disable @typescript-eslint/no-explicit-any */
10+
import { JsonValue } from '../utils';
1011
import { SchemaFormat } from './interface';
1112
import { CoreSchemaRegistry, SchemaValidationException } from './registry';
13+
import { JsonSchema } from './schema';
1214
import { addUndefinedDefaults } from './transforms';
1315

1416
describe('CoreSchemaRegistry', () => {
@@ -126,7 +128,7 @@ describe('CoreSchemaRegistry', () => {
126128
const result = await validator(data);
127129
expect(result.success).toBe(false);
128130
expect(new SchemaValidationException(result.errors).message).toContain(
129-
`Data path "/packageManager" must be equal to one of the allowed values. Allowed values are: "npm", "yarn", "pnpm".`,
131+
`Option at "/packageManager" must be equal to one of the allowed values. Allowed values are: "npm", "yarn", "pnpm".`,
130132
);
131133
});
132134

@@ -340,4 +342,96 @@ describe('CoreSchemaRegistry', () => {
340342
expect(deprecatedMessages[1]).toBe('Option "bar" is deprecated.');
341343
expect(result.success).toBe(true, result.errors);
342344
});
345+
346+
describe('error messages', () => {
347+
async function messagesFor(schema: JsonSchema, data: JsonValue): Promise<string[]> {
348+
const registry = new CoreSchemaRegistry();
349+
const validator = await registry.compile(schema);
350+
const result = await validator(data);
351+
expect(result.success).toBe(false);
352+
353+
return SchemaValidationException.createMessages(result.errors);
354+
}
355+
356+
it('names an unknown option and the options that are valid there', async () => {
357+
const messages = await messagesFor(
358+
{
359+
properties: { version: { type: 'number' }, projects: { type: 'object' } },
360+
additionalProperties: false,
361+
},
362+
{ allowedCommonJsDependencies: [] },
363+
);
364+
365+
expect(messages).toEqual([
366+
'Unknown option "allowedCommonJsDependencies" at the top level. ' +
367+
'Valid options are: version, projects.',
368+
]);
369+
});
370+
371+
it('points at the object an unknown option was found in', async () => {
372+
const messages = await messagesFor(
373+
{
374+
properties: {
375+
cli: {
376+
type: 'object',
377+
properties: { cache: { type: 'object' }, packageManager: { type: 'string' } },
378+
additionalProperties: false,
379+
},
380+
},
381+
},
382+
{ cli: { completion: true } },
383+
);
384+
385+
expect(messages).toEqual([
386+
'Unknown option "completion" at "/cli". Valid options are: cache, packageManager.',
387+
]);
388+
});
389+
390+
it('looks through a $ref for the valid options', async () => {
391+
const messages = await messagesFor(
392+
{
393+
$ref: '#/definitions/global',
394+
definitions: {
395+
global: {
396+
type: 'object',
397+
properties: { cli: { type: 'object' }, schematics: { type: 'object' } },
398+
additionalProperties: false,
399+
},
400+
},
401+
},
402+
{ version: 1 },
403+
);
404+
405+
expect(messages).toEqual([
406+
'Unknown option "version" at the top level. Valid options are: cli, schematics.',
407+
]);
408+
});
409+
410+
it('omits the valid options when the schema does not list any', async () => {
411+
const messages = await messagesFor(
412+
{
413+
$ref: '#/definitions/cli',
414+
definitions: {
415+
cli: {
416+
type: 'object',
417+
patternProperties: { '^x-': { type: 'string' } },
418+
additionalProperties: false,
419+
},
420+
},
421+
},
422+
{ completion: true },
423+
);
424+
425+
expect(messages).toEqual(['Unknown option "completion" at the top level.']);
426+
});
427+
428+
it('reports a value of the wrong type', async () => {
429+
const messages = await messagesFor(
430+
{ properties: { outputPath: { type: 'string' } } },
431+
{ outputPath: 42 },
432+
);
433+
434+
expect(messages).toEqual(['Option at "/outputPath" must be string.']);
435+
});
436+
});
343437
});

tests/e2e/tests/commands/config/config-global-validation.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,15 @@ export default async function () {
99
let ngError: Error;
1010

1111
ngError = await expectToFail(() => silentNg('config', 'cli.completion.prompted', 'true'));
12-
assert.match(
13-
ngError.message,
14-
/Data path "\/cli" must NOT have additional properties\(completion\)\./,
15-
);
12+
assert.match(ngError.message, /Unknown option "completion" at "\/cli"\./);
1613

1714
ngError = await expectToFail(() =>
1815
silentNg('config', '--global', 'cli.completion.invalid', 'true'),
1916
);
20-
assert.match(
21-
ngError.message,
22-
/Data path "\/cli\/completion" must NOT have additional properties\(invalid\)\./,
23-
);
17+
assert.match(ngError.message, /Unknown option "invalid" at "\/cli\/completion"\./);
2418

2519
ngError = await expectToFail(() => silentNg('config', '--global', 'cli.cache.enabled', 'true'));
26-
assert.match(ngError.message, /Data path "\/cli" must NOT have additional properties\(cache\)\./);
20+
assert.match(ngError.message, /Unknown option "cache" at "\/cli"\./);
2721

2822
ngError = await expectToFail(() => silentNg('config', 'cli.completion.prompted'));
2923
assert.match(ngError.message, /Value cannot be found\./);

tests/e2e/tests/commands/config/config-set.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@ export default async function () {
66
let ngError: Error;
77

88
ngError = await expectToFail(() => silentNg('config', 'cli.warnings.zzzz', 'true'));
9-
assert.match(
10-
ngError.message,
11-
/Data path "\/cli\/warnings" must NOT have additional properties\(zzzz\)\./,
12-
);
9+
assert.match(ngError.message, /Unknown option "zzzz" at "\/cli\/warnings"\./);
1310

1411
ngError = await expectToFail(() => silentNg('config', 'cli.warnings.zzzz'));
1512
assert.match(ngError.message, /Value cannot be found\./);

0 commit comments

Comments
 (0)