Skip to content

Commit 79842f5

Browse files
feat: add strictFlags option to reject unknown flags with suggestions
1 parent 97751ee commit 79842f5

File tree

7 files changed

+312
-0
lines changed

7 files changed

+312
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"@types/node": "^24.9.2",
5959
"clean-pkg-json": "^1.3.0",
6060
"expect-type": "^1.2.2",
61+
"fastest-levenshtein": "^1.0.16",
6162
"kolorist": "^1.8.0",
6263
"lintroll": "^1.24.2",
6364
"manten": "^1.5.0",

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { typeFlag } from 'type-flag';
2+
import { closest, distance } from 'fastest-levenshtein';
23
import type {
34
CallbackFunction,
45
CliOptions,
@@ -117,6 +118,52 @@ function helpEnabled(help: false | undefined | HelpOptions): help is (HelpOption
117118
return help !== false;
118119
}
119120

121+
const getKnownFlagNames = (flags: Record<string, unknown>): string[] => {
122+
const names: string[] = [];
123+
for (const [name, config] of Object.entries(flags)) {
124+
names.push(name);
125+
if (config && typeof config === 'object' && 'alias' in config) {
126+
const { alias } = config as { alias?: string | string[] };
127+
if (typeof alias === 'string' && alias) {
128+
names.push(alias);
129+
} else if (Array.isArray(alias)) {
130+
names.push(...alias.filter(Boolean));
131+
}
132+
}
133+
}
134+
return names;
135+
};
136+
137+
const findClosestFlag = (
138+
unknown: string,
139+
knownFlags: string[],
140+
): string | undefined => {
141+
// Don't suggest for very short flags (e.g. -a vs -b)
142+
if (unknown.length < 3 || knownFlags.length === 0) {
143+
return undefined;
144+
}
145+
const match = closest(unknown, knownFlags);
146+
return distance(unknown, match) <= 2 ? match : undefined;
147+
};
148+
149+
const handleUnknownFlags = (
150+
unknownFlags: Record<string, unknown>,
151+
knownFlagNames: string[],
152+
): void => {
153+
const unknownFlagNames = Object.keys(unknownFlags);
154+
if (unknownFlagNames.length === 0) {
155+
return;
156+
}
157+
158+
for (const flag of unknownFlagNames) {
159+
const closestMatch = findClosestFlag(flag, knownFlagNames);
160+
const suggestion = closestMatch ? ` (did you mean --${closestMatch}?)` : '';
161+
console.error(`Error: Unknown flag --${flag}${suggestion}`);
162+
}
163+
164+
process.exit(1);
165+
};
166+
120167
function cliBase<
121168
CommandName extends string | undefined,
122169
Options extends CliOptionsInternal,
@@ -199,6 +246,13 @@ function cliBase<
199246
return process.exit(0);
200247
}
201248

249+
// Check for unknown flags if strictFlags is enabled
250+
// Inherit from parent if not explicitly set
251+
const strictFlags = options.strictFlags ?? options.parent?.strictFlags;
252+
if (strictFlags) {
253+
handleUnknownFlags(parsed.unknownFlags, getKnownFlagNames(flags));
254+
}
255+
202256
if (options.parameters) {
203257
let { parameters } = options;
204258
let cliArguments = parsed._ as string[];

src/command.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ export type CommandOptions<Parameters = string[]> = {
4444
* Which argv elements to ignore from parsing
4545
*/
4646
ignoreArgv?: IgnoreFunction;
47+
48+
/**
49+
* When enabled, prints an error and exits if unknown flags are passed.
50+
* Suggests the closest matching flag name when possible.
51+
* Inherits from parent CLI if not specified.
52+
*/
53+
strictFlags?: boolean;
4754
};
4855

4956
export function command<

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,12 @@ export type CliOptions<
133133
* Which argv elements to ignore from parsing
134134
*/
135135
ignoreArgv?: IgnoreFunction;
136+
137+
/**
138+
* When enabled, prints an error and exits if unknown flags are passed.
139+
* Suggests the closest matching flag name when possible.
140+
*/
141+
strictFlags?: boolean;
136142
};
137143

138144
export type CliOptionsInternal<

tests/specs/command.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { setImmediate } from 'node:timers/promises';
22
import { testSuite, expect } from 'manten';
33
import { spy } from 'nanospy';
4+
import { mockEnvFunctions } from '../utils/mock-env-functions';
45
import { cli, command } from '#cleye';
56

67
export default testSuite(({ describe }) => {
@@ -441,5 +442,83 @@ export default testSuite(({ describe }) => {
441442
expect(cliResolved).toBe(false);
442443
});
443444
});
445+
446+
describe('strictFlags inheritance', ({ test }) => {
447+
test('command inherits strictFlags from parent', () => {
448+
const mocked = mockEnvFunctions();
449+
450+
const buildCommand = command({
451+
name: 'build',
452+
flags: {
453+
watch: Boolean,
454+
},
455+
});
456+
457+
cli(
458+
{
459+
strictFlags: true,
460+
commands: [buildCommand],
461+
},
462+
undefined,
463+
['build', '--wathc'],
464+
);
465+
mocked.restore();
466+
467+
expect(mocked.consoleError.called).toBe(true);
468+
expect(mocked.consoleError.calls[0][0]).toContain('--wathc');
469+
expect(mocked.consoleError.calls[0][0]).toContain('--watch');
470+
expect(mocked.processExit.calls).toStrictEqual([[1]]);
471+
});
472+
473+
test('command can override strictFlags to false', () => {
474+
const mocked = mockEnvFunctions();
475+
476+
const buildCommand = command({
477+
name: 'build',
478+
flags: {
479+
watch: Boolean,
480+
},
481+
strictFlags: false,
482+
});
483+
484+
const parsed = cli(
485+
{
486+
strictFlags: true,
487+
commands: [buildCommand],
488+
},
489+
undefined,
490+
['build', '--unknown'],
491+
);
492+
mocked.restore();
493+
494+
expect(mocked.consoleError.called).toBe(false);
495+
expect(mocked.processExit.called).toBe(false);
496+
expect(parsed.unknownFlags.unknown).toEqual([true]);
497+
});
498+
499+
test('command can enable strictFlags independently', () => {
500+
const mocked = mockEnvFunctions();
501+
502+
const buildCommand = command({
503+
name: 'build',
504+
flags: {
505+
watch: Boolean,
506+
},
507+
strictFlags: true,
508+
});
509+
510+
cli(
511+
{
512+
commands: [buildCommand],
513+
},
514+
undefined,
515+
['build', '--wathc'],
516+
);
517+
mocked.restore();
518+
519+
expect(mocked.consoleError.called).toBe(true);
520+
expect(mocked.processExit.calls).toStrictEqual([[1]]);
521+
});
522+
});
444523
});
445524
});

tests/specs/flags.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,5 +376,161 @@ export default testSuite(({ describe }) => {
376376
expect(parsed.unknownFlags.unknown2).toEqual([true]);
377377
});
378378
});
379+
380+
describe('strictFlags', ({ test }) => {
381+
test('errors on unknown flag', () => {
382+
const mocked = mockEnvFunctions();
383+
cli(
384+
{
385+
flags: {
386+
verbose: Boolean,
387+
},
388+
strictFlags: true,
389+
},
390+
undefined,
391+
['--unknown'],
392+
);
393+
mocked.restore();
394+
395+
expect(mocked.consoleError.called).toBe(true);
396+
expect(mocked.consoleError.calls[0][0]).toContain('Unknown flag');
397+
expect(mocked.consoleError.calls[0][0]).toContain('--unknown');
398+
expect(mocked.processExit.calls).toStrictEqual([[1]]);
399+
});
400+
401+
test('suggests closest match when within distance 2', () => {
402+
const mocked = mockEnvFunctions();
403+
cli(
404+
{
405+
flags: {
406+
verbose: Boolean,
407+
},
408+
strictFlags: true,
409+
},
410+
undefined,
411+
['--verbos'], // Missing 'e'
412+
);
413+
mocked.restore();
414+
415+
expect(mocked.consoleError.calls[0][0]).toContain('--verbose');
416+
expect(mocked.consoleError.calls[0][0]).toMatch(/did you mean/i);
417+
});
418+
419+
test('no suggestion when flag is too different', () => {
420+
const mocked = mockEnvFunctions();
421+
cli(
422+
{
423+
flags: {
424+
verbose: Boolean,
425+
},
426+
strictFlags: true,
427+
},
428+
undefined,
429+
['--xyz'],
430+
);
431+
mocked.restore();
432+
433+
expect(mocked.consoleError.calls[0][0]).not.toMatch(/did you mean/i);
434+
});
435+
436+
test('no suggestion for very short unknown flags', () => {
437+
const mocked = mockEnvFunctions();
438+
cli(
439+
{
440+
flags: {
441+
ab: Boolean,
442+
ac: Boolean,
443+
},
444+
strictFlags: true,
445+
},
446+
undefined,
447+
['--ad'], // Short unknown flag (2 chars) shouldn't get suggestions
448+
);
449+
mocked.restore();
450+
451+
expect(mocked.consoleError.called).toBe(true);
452+
expect(mocked.consoleError.calls[0][0]).toContain('--ad');
453+
expect(mocked.consoleError.calls[0][0]).not.toMatch(/did you mean/i);
454+
});
455+
456+
test('reports multiple unknown flags', () => {
457+
const mocked = mockEnvFunctions();
458+
cli(
459+
{
460+
flags: {
461+
verbose: Boolean,
462+
output: String,
463+
},
464+
strictFlags: true,
465+
},
466+
undefined,
467+
['--verbos', '--outpu'],
468+
);
469+
mocked.restore();
470+
471+
expect(mocked.consoleError.callCount).toBe(2);
472+
expect(mocked.consoleError.calls[0][0]).toContain('--verbos');
473+
expect(mocked.consoleError.calls[1][0]).toContain('--outpu');
474+
});
475+
476+
test('known flags still work', () => {
477+
const mocked = mockEnvFunctions();
478+
const parsed = cli(
479+
{
480+
flags: {
481+
verbose: Boolean,
482+
output: String,
483+
},
484+
strictFlags: true,
485+
},
486+
undefined,
487+
['--verbose', '--output', 'file.txt'],
488+
);
489+
mocked.restore();
490+
491+
expect(mocked.consoleError.called).toBe(false);
492+
expect(mocked.processExit.called).toBe(false);
493+
expect(parsed.flags.verbose).toBe(true);
494+
expect(parsed.flags.output).toBe('file.txt');
495+
});
496+
497+
test('strictFlags disabled by default', () => {
498+
const mocked = mockEnvFunctions();
499+
const parsed = cli(
500+
{
501+
flags: {
502+
verbose: Boolean,
503+
},
504+
},
505+
undefined,
506+
['--unknown'],
507+
);
508+
mocked.restore();
509+
510+
expect(mocked.consoleError.called).toBe(false);
511+
expect(mocked.processExit.called).toBe(false);
512+
expect(parsed.unknownFlags.unknown).toEqual([true]);
513+
});
514+
515+
test('suggests flag aliases', () => {
516+
const mocked = mockEnvFunctions();
517+
cli(
518+
{
519+
flags: {
520+
verbose: {
521+
type: Boolean,
522+
alias: 'v',
523+
},
524+
},
525+
strictFlags: true,
526+
},
527+
undefined,
528+
['--verbos'],
529+
);
530+
mocked.restore();
531+
532+
expect(mocked.consoleError.calls[0][0]).toContain('--verbose');
533+
});
534+
});
379535
});
380536
});

0 commit comments

Comments
 (0)