Skip to content

Commit b5cc572

Browse files
committed
fix(commands): infer the class form's result type and validate its meta eagerly
Command() took a third type parameter for the run result that nothing could infer, so a class whose run returned a value only compiled once the author restated the name and the option schema as type arguments. CommandBase now declares run as returning unknown and types postRun off the subclass's own run through the polymorphic this, so the result is inferred and CommandClass has two parameters. The meta passed to Command() was only checked on the first read of the class's definition, which for a registered built-in is the first resolution in a user's terminal. It is now validated at the Command() call itself: unknown fields, bad option or argument specs, and handlers passed as meta rather than declared as methods all throw there. Only a missing run method still waits for the definition, and that message no longer leaks the factory's local class name.
1 parent c0f8226 commit b5cc572

5 files changed

Lines changed: 177 additions & 36 deletions

File tree

‎defining-commands.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ A definition is checked at the moment `defineCommand` is called, not when the
5959
command eventually runs. A misspelled field, a missing `run`, an option
6060
declared with something other than the four helpers, an `arguments` value
6161
outside `"none" | "any"` — each throws immediately, naming the command and the
62-
accepted form:
62+
accepted form. The class form's meta is checked the same way at the
63+
`Command({ ... })` call, which also rejects handlers passed there; only a
64+
missing `run` method waits until the definition is first read:
6365

6466
```
6567
Invalid command definition for 'widget|add': unknown field(s) 'handler'; a
@@ -563,7 +565,8 @@ export class PlatformCleanCommand extends Command({
563565
`arguments`, `allowUnknownOptions`, `disableAnalytics` and `enableHooks`. The
564566
handlers are methods instead — `run` is required, and `canExecute`, `postRun`
565567
and `shortcuts` are optional, each with the same meaning and the same ordering
566-
as the fields of the same name. `postRun(result)` receives what `run` returned;
568+
as the fields of the same name. The result type is inferred from `run`, and
569+
`postRun(result)` receives it, awaited, with no type argument to restate;
567570
`shortcuts()` returns the same table `shortcuts(ctx, setup)` does. A method the
568571
class does not declare is left out of the definition entirely, so a class
569572
without `postRun` gets no `postCommandAction`, exactly as an object without one

‎lib/commands/create-project.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -368,11 +368,7 @@ async function interactiveFlavorAndTemplateSelection(
368368
);
369369
}
370370

371-
export class CreateProjectCommand extends Command<
372-
"create",
373-
typeof createProjectCommandOptions,
374-
ICreateProjectData
375-
>({
371+
export class CreateProjectCommand extends Command({
376372
name: "create",
377373
description: "Creates a new NativeScript project.",
378374
options: createProjectCommandOptions,

‎lib/common/define-command.ts‎

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ export interface CommandContext<TSchema extends CommandOptionsSchema = {}> {
117117
/** Current value of every option declared in the schema, and nothing else. */
118118
options: CommandOptionValues<TSchema>;
119119
/**
120-
* The injector the command was registered against. `inject()` stops working
121-
* after the first `await`; this is the supported late lookup.
120+
* This invocation's injector, the one `inject()` resolves against before the
121+
* first `await`; after it, `inject()` stops working and this is the lookup.
122122
*/
123123
injector: Injector;
124124
/** Fails the command with `message` and the usage help suggestion. */
@@ -539,6 +539,49 @@ const validateDefinition = (definition: any): void => {
539539
}
540540
};
541541

542+
const HANDLER_FIELDS = ["setup", "canExecute", "run", "postRun", "shortcuts"];
543+
544+
const META_FIELDS = DEFINITION_FIELDS.filter(
545+
(field) => HANDLER_FIELDS.indexOf(field) === -1,
546+
);
547+
548+
/**
549+
* Everything defineCommand checks except the handlers, which the class form
550+
* only has once the subclass is declared, after Command() has returned.
551+
*/
552+
const validateMeta = (meta: any): void => {
553+
if (!isPlainObject(meta)) {
554+
invalid(meta, "Command() expects an object");
555+
}
556+
557+
const fields = Object.keys(meta);
558+
const handlers = fields.filter(
559+
(field) => HANDLER_FIELDS.indexOf(field) !== -1,
560+
);
561+
if (handlers.length) {
562+
invalid(
563+
meta,
564+
`Command() was given the handler(s) ${handlers
565+
.map((field) => `'${field}'`)
566+
.join(", ")}; in the class form handlers are methods of the class`,
567+
);
568+
}
569+
570+
const unknownFields = fields.filter(
571+
(field) => META_FIELDS.indexOf(field) === -1,
572+
);
573+
if (unknownFields.length) {
574+
invalid(
575+
meta,
576+
`unknown field(s) ${unknownFields
577+
.map((field) => `'${field}'`)
578+
.join(", ")}; Command() accepts ${META_FIELDS.join(", ")}`,
579+
);
580+
}
581+
582+
validateDefinition({ ...meta, run: (): void => undefined });
583+
};
584+
542585
/**
543586
* A definition that carries the name it declares in its own type. `Omit` rather
544587
* than an intersection: intersecting the declared name with the wider `name` of
@@ -629,10 +672,7 @@ export type CommandMeta<
629672
* every `Command()` class — a subclass's declaration emit refers to it — not
630673
* because anything should extend it directly.
631674
*/
632-
export abstract class CommandBase<
633-
TSchema extends CommandOptionsSchema = {},
634-
TResult = void,
635-
> {
675+
export abstract class CommandBase<TSchema extends CommandOptionsSchema = {}> {
636676
/**
637677
* The instance is built once per invocation, as that invocation's `setup`,
638678
* so the context captured here is the one its own run was handed.
@@ -647,9 +687,10 @@ export abstract class CommandBase<
647687
return this.context.args;
648688
}
649689

650-
abstract run(): Promise<TResult> | TResult;
690+
abstract run(): unknown;
651691
canExecute?(): Promise<boolean> | boolean;
652-
postRun?(result: Awaited<TResult>): Promise<void> | void;
692+
/** Typed off the subclass's own `run` through the polymorphic `this`. */
693+
postRun?(result: Awaited<ReturnType<this["run"]>>): Promise<void> | void;
653694
shortcuts?(): KeyShortcut[];
654695
}
655696

@@ -661,24 +702,16 @@ export abstract class CommandBase<
661702
export type CommandClass<
662703
TName extends CommandName = CommandName,
663704
TSchema extends CommandOptionsSchema = {},
664-
TResult = void,
665-
> = (abstract new () => CommandBase<TSchema, TResult>) & {
666-
readonly definition: NamedCommand<
667-
TSchema,
668-
TResult,
669-
CommandBase<TSchema, TResult>,
670-
TName
671-
>;
705+
> = (abstract new () => CommandBase<TSchema>) & {
706+
readonly definition: NamedCommand<TSchema, any, CommandBase<TSchema>, TName>;
672707
readonly [COMMAND_CLASS_MARKER]: true;
673708
};
674709

675710
/** Either accepted form of a command, as a registration site takes it. */
676711
export type RegisterableCommand =
677-
DefinedCommand<any, any, any> | CommandClass<any, any, any>;
712+
DefinedCommand<any, any, any> | CommandClass<any, any>;
678713

679-
export function isCommandClass(
680-
value: any,
681-
): value is CommandClass<any, any, any> {
714+
export function isCommandClass(value: any): value is CommandClass<any, any> {
682715
return (
683716
typeof value === "function" && (<any>value)[COMMAND_CLASS_MARKER] === true
684717
);
@@ -691,9 +724,19 @@ const buildClassDefinition = (ctor: any): DefinedCommand<any, any, any> => {
691724
typeof prototype[method] === "function";
692725

693726
if (!implementsMethod("run")) {
727+
// The base Command() returns is never the author's class, and its
728+
// local name would only mislead.
729+
const isFactoryBase = Object.prototype.hasOwnProperty.call(
730+
ctor,
731+
COMMAND_CLASS_META,
732+
);
694733
invalid(
695734
meta,
696-
`the class '${ctor.name || "<anonymous>"}' implements no 'run' method`,
735+
isFactoryBase
736+
? "the class returned by Command() implements no 'run' method; extend it with a class that does"
737+
: ctor.name
738+
? `the class '${ctor.name}' implements no 'run' method`
739+
: "an anonymous class implements no 'run' method",
697740
);
698741
}
699742

@@ -785,9 +828,10 @@ export type CommandReference = string | RegisterableCommand;
785828
export function Command<
786829
const TName extends CommandName,
787830
TSchema extends CommandOptionsSchema = {},
788-
TResult = void,
789-
>(meta: CommandMeta<TName, TSchema>): CommandClass<TName, TSchema, TResult> {
790-
abstract class Base extends CommandBase<TSchema, TResult> {
831+
>(meta: CommandMeta<TName, TSchema>): CommandClass<TName, TSchema> {
832+
validateMeta(meta);
833+
834+
abstract class Base extends CommandBase<TSchema> {
791835
// A getter, because `this` in a static accessor is the constructor the
792836
// property was read through: that is the only hook that resolves the
793837
// subclass without the subclass having to name itself.

‎test/define-command.ts‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2609,7 +2609,7 @@ describe("defineCommand", () => {
26092609
const testInjector = createTestInjector();
26102610
const order: string[] = [];
26112611

2612-
class Widget extends Command<"dctest-class-postrun", {}, number>({
2612+
class Widget extends Command({
26132613
name: "dctest-class-postrun",
26142614
}) {
26152615
public run(): number {
@@ -2726,6 +2726,66 @@ describe("defineCommand", () => {
27262726
);
27272727
});
27282728

2729+
describe("define-time validation", () => {
2730+
it("rejects an unusable name at the Command() call", () => {
2731+
assert.throws(
2732+
() => Command({ name: "" }),
2733+
/Invalid command definition for an unnamed command.*'name' must be/s,
2734+
);
2735+
});
2736+
2737+
it("rejects an unknown meta field at the Command() call, naming it", () => {
2738+
assert.throws(
2739+
() => Command(<any>{ name: "dctest-class-typo", typo: 1 }),
2740+
/Invalid command definition for 'dctest-class-typo'.*unknown field\(s\) 'typo'; Command\(\) accepts/s,
2741+
);
2742+
});
2743+
2744+
it("rejects a bad option spec at the Command() call", () => {
2745+
assert.throws(
2746+
() =>
2747+
Command({
2748+
name: "dctest-class-badoption",
2749+
options: { flag: <any>{ type: "flag" } },
2750+
}),
2751+
/Invalid command definition for 'dctest-class-badoption'.*option 'flag' has type 'flag'/s,
2752+
);
2753+
});
2754+
2755+
it("rejects a handler passed in the meta", () => {
2756+
assert.throws(
2757+
() =>
2758+
Command(<any>{
2759+
name: "dctest-class-metahandler",
2760+
canExecute(): boolean {
2761+
return true;
2762+
},
2763+
}),
2764+
/Invalid command definition for 'dctest-class-metahandler'.*handler\(s\) 'canExecute'; in the class form handlers are methods of the class/s,
2765+
);
2766+
});
2767+
2768+
it("leaves the missing run to the first definition read", () => {
2769+
const base: any = Command({ name: "dctest-class-lazyrun" });
2770+
const [Anonymous] = [class extends base {}];
2771+
class Named extends base {}
2772+
2773+
for (const ctor of [base, Anonymous, Named]) {
2774+
let message = "";
2775+
try {
2776+
ctor.definition;
2777+
} catch (err) {
2778+
message = err.message;
2779+
}
2780+
2781+
assert.match(message, /implements no 'run' method/);
2782+
assert.notInclude(message, "'Base'");
2783+
}
2784+
2785+
assert.throws(() => (<any>Named).definition, /the class 'Named'/);
2786+
});
2787+
});
2788+
27292789
it("reports a class Command() did not produce, through the deferred loader", () => {
27302790
const testInjector = createTestInjector();
27312791

‎test/type-fixtures/define-command-types.ts‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,10 +275,10 @@ class TypefixturePlatformClean extends Command({
275275
}
276276
}
277277

278-
class TypefixtureResult extends Command<"typefixture|class-result", {}, number>(
279-
{ name: "typefixture|class-result" },
280-
) {
281-
run(): number {
278+
// The result type is inferred from run; postRun receives it without the
279+
// class restating it.
280+
class TypefixtureResult extends Command({ name: "typefixture|class-result" }) {
281+
run() {
282282
return 1;
283283
}
284284

@@ -287,6 +287,44 @@ class TypefixtureResult extends Command<"typefixture|class-result", {}, number>(
287287
}
288288
}
289289

290+
class TypefixtureResultMismatch extends Command({
291+
name: "typefixture|class-result-mismatch",
292+
}) {
293+
run() {
294+
return 1;
295+
}
296+
297+
// @ts-expect-error - run returns a number, so postRun cannot take a string
298+
postRun(result: string): void {
299+
return undefined;
300+
}
301+
}
302+
303+
class TypefixtureAsyncResult extends Command({
304+
name: "typefixture|class-async-result",
305+
}) {
306+
async run() {
307+
return { created: true, path: "/tmp/app" };
308+
}
309+
310+
postRun(result: { created: boolean; path: string }): void {
311+
return undefined;
312+
}
313+
}
314+
315+
class TypefixtureAsyncResultMismatch extends Command({
316+
name: "typefixture|class-async-result-mismatch",
317+
}) {
318+
async run() {
319+
return { created: true };
320+
}
321+
322+
// @ts-expect-error - postRun receives the awaited object, not a string flag
323+
postRun(result: { created: string }): void {
324+
return undefined;
325+
}
326+
}
327+
290328
// @ts-expect-error - run is abstract; a command class has to implement it
291329
class TypefixtureNoRun extends Command({ name: "typefixture|class-no-run" }) {}
292330

0 commit comments

Comments
 (0)