Skip to content

Commit 8c43889

Browse files
thekhegayalan-agius4
authored andcommitted
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". Valid options are: assets, browser, ...`, with `at "/cli"` where the instance path is not empty. Errors that are not about an unknown option are unchanged. 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 after the option name rather than offering an empty list.
1 parent 8e1b202 commit 8c43889

4 files changed

Lines changed: 114 additions & 25 deletions

File tree

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

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

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

7984
return message + '.';
@@ -106,6 +111,8 @@ export class CoreSchemaRegistry implements SchemaRegistry {
106111
strict: false,
107112
loadSchema: (uri: string) => this._fetch(uri),
108113
passContext: true,
114+
// Needed to list the valid options of the object an unknown option was found in.
115+
verbose: true,
109116
});
110117

111118
ajvAddFormats(this._ajv);

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

Lines changed: 91 additions & 0 deletions
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', () => {
@@ -340,4 +342,93 @@ 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". Valid options are: version, projects.',
367+
]);
368+
});
369+
370+
it('points at the object an unknown option was found in', async () => {
371+
const messages = await messagesFor(
372+
{
373+
properties: {
374+
cli: {
375+
type: 'object',
376+
properties: { cache: { type: 'object' }, packageManager: { type: 'string' } },
377+
additionalProperties: false,
378+
},
379+
},
380+
},
381+
{ cli: { completion: true } },
382+
);
383+
384+
expect(messages).toEqual([
385+
'Unknown option "completion" at "/cli". Valid options are: cache, packageManager.',
386+
]);
387+
});
388+
389+
it('looks through a $ref for the valid options', async () => {
390+
const messages = await messagesFor(
391+
{
392+
$ref: '#/definitions/global',
393+
definitions: {
394+
global: {
395+
type: 'object',
396+
properties: { cli: { type: 'object' }, schematics: { type: 'object' } },
397+
additionalProperties: false,
398+
},
399+
},
400+
},
401+
{ version: 1 },
402+
);
403+
404+
expect(messages).toEqual(['Unknown option "version". Valid options are: cli, schematics.']);
405+
});
406+
407+
it('omits the valid options when the schema does not list any', async () => {
408+
const messages = await messagesFor(
409+
{
410+
$ref: '#/definitions/cli',
411+
definitions: {
412+
cli: {
413+
type: 'object',
414+
patternProperties: { '^x-': { type: 'string' } },
415+
additionalProperties: false,
416+
},
417+
},
418+
},
419+
{ completion: true },
420+
);
421+
422+
expect(messages).toEqual(['Unknown option "completion".']);
423+
});
424+
425+
it('leaves an error that is not about an unknown option alone', async () => {
426+
const messages = await messagesFor(
427+
{ properties: { outputPath: { type: 'string' } } },
428+
{ outputPath: 42 },
429+
);
430+
431+
expect(messages).toEqual(['Data path "/outputPath" must be string.']);
432+
});
433+
});
343434
});

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)