Skip to content

Commit 6502f13

Browse files
committed
fix(options): revive unknown-option validation as a staged warning
validateOptions built its lookup map keyed by each option's stringified *value* rather than its name, so every entry resolved to undefined and the consumer loop skipped all of them. No unknown option, and no malformed option value, has been reported since. Iterate the parsed argv keys instead. yargs reports both the dashed and the camelCase spelling of every flag, so getCorrectOptionName now also resolves a camelCase key back to a literal dashed declaration -- without that, the vision-ng family rejects itself -- and the loop reports each option once rather than once per spelling. Five years of silence means an unknown flag may well be load-bearing in somebody's script, and the value-shape checks have been dead just as long. Both now warn by default and only fail when NS_STRICT_OPTIONS=error, so the hard-fail can be previewed before it ships. Commands that forward their options to another CLI cannot be validated against this CLI's dictionary; they opt out with skipOptionsValidation. preview, which passes raw argv to @nativescript/preview-cli, is the first.
1 parent a6b07c1 commit 6502f13

5 files changed

Lines changed: 96 additions & 47 deletions

File tree

lib/commands/preview.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ const PREVIEW_CLI_PACKAGE = "@nativescript/preview-cli";
1212

1313
export class PreviewCommand implements ICommand {
1414
allowedParameters: ICommandParameter[] = [];
15+
skipOptionsValidation = true;
1516

1617
constructor(
1718
private $logger: ILogger,
1819
private $errors: IErrors,
1920
private $projectData: IProjectData,
2021
private $packageManager: IPackageManager,
2122
private $childProcess: IChildProcess,
22-
private $options: IOptions
23+
private $options: IOptions,
2324
) {}
2425

2526
private getPreviewCLIPath(): string {
@@ -37,7 +38,7 @@ export class PreviewCommand implements ICommand {
3738
{
3839
"save-dev": true,
3940
"save-exact": true,
40-
} as any
41+
} as any,
4142
);
4243
}
4344

@@ -78,7 +79,7 @@ export class PreviewCommand implements ICommand {
7879
color.cyan(" ./node_modules/.bin/preview-cli"),
7980
"",
8081
"And if you are still having issues, try again - or reach out on Discord/open an issue on GitHub.",
81-
].join("\n")
82+
].join("\n"),
8283
);
8384

8485
this.$errors.fail("Running preview failed.");
@@ -93,7 +94,7 @@ export class PreviewCommand implements ICommand {
9394
[previewCLIBinPath, ...commandArgs],
9495
{
9596
stdio: "inherit",
96-
}
97+
},
9798
);
9899
}
99100

lib/common/definitions/commands.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ interface ICommand extends ICommandOptions {
1616
dashedOptions?: IDictionary<IDashedOption>;
1717
isHierarchicalCommand?: boolean;
1818

19+
/**
20+
* Set on commands that forward their options to another CLI: the options
21+
* they accept are not knowable from this CLI's option dictionary, so
22+
* validating them here would reject the other CLI's flags.
23+
*/
24+
skipOptionsValidation?: boolean;
25+
1926
/**
2027
* Describes the action that will be executed after the command succeeds.
2128
* @param {string[]} args Arguments passed to the command.

lib/common/services/commands-service.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,10 @@ export class CommandsService implements ICommandsService {
158158
commandArguments: string[],
159159
): Promise<boolean> {
160160
const command = this.$injector.resolveCommand(commandName);
161-
if (!command || !command.isHierarchicalCommand) {
161+
if (
162+
!command ||
163+
(!command.isHierarchicalCommand && !command.skipOptionsValidation)
164+
) {
162165
const dashedOptions = command ? command.dashedOptions : null;
163166
this.$options.validateOptions(dashedOptions);
164167
}

lib/options.ts

Lines changed: 79 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export class Options {
6868
constructor(
6969
private $errors: IErrors,
7070
private $settingsService: ISettingsService,
71+
private $logger: ILogger,
7172
) {
7273
this.options = _.extend({}, this.commonOptions, this.globalOptions);
7374
this.setArgv();
@@ -261,62 +262,98 @@ export class Options {
261262
commandSpecificDashedOptions?: IDictionary<IDashedOption>,
262263
): void {
263264
this.setupOptions(commandSpecificDashedOptions);
264-
const parsed: any = {};
265-
for (const key of Object.keys(this.argv)) {
266-
const optionName = `${this.argv[key]}`;
267-
parsed[optionName] = this.getOptionValue(optionName);
268-
}
269265

270-
_.each(parsed, (value: any, originalOptionName: string) => {
271-
// when this.options are passed to yargs, it returns all of them and the ones that are not part of process.argv are set to undefined.
272-
if (value === undefined) {
273-
return;
266+
const validated: string[] = [];
267+
for (const originalOptionName of Object.keys(this.argv)) {
268+
const optionValue = this.getOptionValue(originalOptionName);
269+
// yargs reports every declared option; the ones that are not part of
270+
// process.argv come back undefined.
271+
if (optionValue === undefined) {
272+
continue;
274273
}
275274

276275
const optionName = this.getCorrectOptionName(originalOptionName);
277276

278-
if (!_.includes(this.optionsWhiteList, optionName)) {
279-
if (!this.isOptionSupported(optionName)) {
280-
this.$errors.failWithHelp(
281-
`The option '${originalOptionName}' is not supported.`,
282-
);
283-
}
277+
if (_.includes(this.optionsWhiteList, optionName)) {
278+
continue;
279+
}
284280

285-
const optionType = this.getOptionType(optionName),
286-
optionValue = parsed[optionName];
281+
// yargs emits both the dashed and the camelCase spelling of every flag.
282+
// Unknown options have no declaration to normalize against, so key the
283+
// dedupe off the camelCase form both spellings collapse to.
284+
const dedupeKey = this.getNonDashedOptionName(optionName);
285+
if (_.includes(validated, dedupeKey)) {
286+
continue;
287+
}
288+
validated.push(dedupeKey);
287289

288-
if (_.isArray(optionValue) && optionType !== OptionType.Array) {
289-
this.$errors.failWithHelp(
290-
"The '%s' option requires a single value.",
291-
originalOptionName,
292-
);
293-
} else if (
294-
optionType === OptionType.String &&
295-
helpers.isNullOrWhitespace(optionValue)
296-
) {
297-
this.$errors.failWithHelp(
298-
"The option '%s' requires non-empty value.",
290+
if (!this.isOptionSupported(optionName)) {
291+
this.reportInvalidOption(
292+
`The option '${this.getReportedOptionName(
299293
originalOptionName,
300-
);
301-
} else if (
302-
optionType === OptionType.Array &&
303-
optionValue.length === 0
304-
) {
305-
this.$errors.failWithHelp(
306-
`The option '${originalOptionName}' requires one or more values, separated by a space.`,
307-
);
308-
}
294+
)}' is not supported.`,
295+
);
296+
continue;
309297
}
310-
});
298+
299+
const optionType = this.getOptionType(optionName);
300+
301+
if (_.isArray(optionValue) && optionType !== OptionType.Array) {
302+
this.reportInvalidOption(
303+
`The '${originalOptionName}' option requires a single value.`,
304+
);
305+
} else if (
306+
optionType === OptionType.String &&
307+
helpers.isNullOrWhitespace(optionValue)
308+
) {
309+
this.reportInvalidOption(
310+
`The option '${originalOptionName}' requires non-empty value.`,
311+
);
312+
} else if (optionType === OptionType.Array && optionValue.length === 0) {
313+
this.reportInvalidOption(
314+
`The option '${originalOptionName}' requires one or more values, separated by a space.`,
315+
);
316+
}
317+
}
311318
}
312319

313-
private getCorrectOptionName(optionName: string): string {
314-
const secondaryOptionName = this.getNonDashedOptionName(optionName);
315-
return _.includes(this.optionNames, secondaryOptionName)
316-
? secondaryOptionName
320+
// yargs strips the `no-` prefix off a negated flag, so an undeclared
321+
// `--no-foo` surfaces as `foo` and would otherwise be reported under a name
322+
// the user never typed.
323+
private getReportedOptionName(optionName: string): string {
324+
return process.argv.indexOf(`--no-${optionName}`) !== -1
325+
? `no-${optionName}`
317326
: optionName;
318327
}
319328

329+
private reportInvalidOption(message: string): void {
330+
if (process.env.NS_STRICT_OPTIONS === "error") {
331+
this.$errors.failWithHelp(message);
332+
return;
333+
}
334+
335+
this.$logger.warn(
336+
`${message} This will become an error in a future release. Set NS_STRICT_OPTIONS=error to preview that behavior.`,
337+
);
338+
}
339+
340+
private getCorrectOptionName(optionName: string): string {
341+
const nonDashedName = this.getNonDashedOptionName(optionName);
342+
if (_.includes(this.optionNames, nonDashedName)) {
343+
return nonDashedName;
344+
}
345+
346+
// A few options are declared with a literal dashed key (vision-ng and
347+
// friends). yargs still reports both spellings, so the camelCase one has
348+
// to resolve back to the dashed declaration.
349+
const dashedName = this.getDashedOptionName(optionName);
350+
if (_.includes(this.optionNames, dashedName)) {
351+
return dashedName;
352+
}
353+
354+
return optionName;
355+
}
356+
320357
private getOptionType(optionName: string): string {
321358
const option =
322359
this.options[optionName] || this.tryGetOptionByAliasName(optionName);

test/options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ function createOptionsWithProfileDir(defaultProfileDir?: string): IOptions {
391391
const testInjector = new Yok();
392392
testInjector.register("errors", {});
393393
testInjector.register("staticConfig", {});
394+
testInjector.register("logger", { warn: (): void => undefined });
394395
let valuePassedToSetSettings: string;
395396
testInjector.register("settingsService", {
396397
setSettings: (settings: IConfigurationSettings): any => {

0 commit comments

Comments
 (0)