Skip to content

Commit d9299d7

Browse files
CopilotAgentEnder
andcommitted
feat(cli-forge,parser): add strict mode to parser and CLI
Co-authored-by: AgentEnder <6933928+AgentEnder@users.noreply.github.com> Fix examples and e2e tests for strict mode Co-authored-by: AgentEnder <6933928+AgentEnder@users.noreply.github.com> Simplify strict mode condition check Co-authored-by: AgentEnder <6933928+AgentEnder@users.noreply.github.com> Add .strict() method to parser and support .strict(false) Co-authored-by: AgentEnder <6933928+AgentEnder@users.noreply.github.com> Add explicit .strict(false) to non-strict-mode example Co-authored-by: AgentEnder <6933928+AgentEnder@users.noreply.github.com>
1 parent afcb44f commit d9299d7

File tree

9 files changed

+249
-1
lines changed

9 files changed

+249
-1
lines changed

e2e/run-examples.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ function runExampleCommand(
8787
) {
8888
const command = typeof config === 'string' ? config : config.command;
8989
const env = typeof config === 'string' ? {} : config.env;
90+
const expectedExitCode = typeof config === 'string' ? 0 : config.exitCode ?? 0;
91+
9092
try {
9193
process.stdout.write('▶️ ' + label);
9294
const a = performance.now();
@@ -96,6 +98,16 @@ function runExampleCommand(
9698
cwd,
9799
}).toString();
98100
const b = performance.now();
101+
102+
// Check if we expected the command to succeed (exit code 0)
103+
if (expectedExitCode !== 0) {
104+
// If we expected a non-zero exit code but got success, that's an error
105+
process.stdout.write('\r');
106+
console.log(`❌ ${label}`.padEnd(process.stdout.columns, ' '));
107+
console.log(`Expected exit code ${expectedExitCode} but got 0`);
108+
return false;
109+
}
110+
99111
checkAssertions(output, config.assertions);
100112
// move cursor to the beginning of the line
101113
process.stdout.write('\r');
@@ -106,7 +118,28 @@ function runExampleCommand(
106118
)
107119
);
108120
} catch (e) {
109-
// move cursor to the beginning of the line
121+
// Command failed - check if this was expected
122+
const actualExitCode = e.status ?? 1;
123+
const output = (e.stdout?.toString() || '') + (e.stderr?.toString() || '');
124+
125+
if (actualExitCode === expectedExitCode) {
126+
// Expected failure - check assertions on the error output
127+
try {
128+
checkAssertions(output, config.assertions);
129+
process.stdout.write('\r');
130+
console.log(
131+
`✅ ${label} (expected failure)`.padEnd(process.stdout.columns, ' ')
132+
);
133+
return true;
134+
} catch (assertionError) {
135+
process.stdout.write('\r');
136+
console.log(`❌ ${label}`.padEnd(process.stdout.columns, ' '));
137+
console.log(assertionError.toString());
138+
return false;
139+
}
140+
}
141+
142+
// Unexpected failure
110143
process.stdout.write('\r');
111144
console.log(`❌ ${label}`.padEnd(process.stdout.columns, ' '));
112145

examples/non-strict-mode.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// ---
2+
// id: non-strict-mode
3+
// title: Non-Strict Mode (Default)
4+
// description: |
5+
// This example demonstrates the default behavior without strict mode.
6+
// Unmatched arguments are collected in the `unmatched` array and don't cause errors.
7+
// commands:
8+
// - command: '{filename} --name World'
9+
// assertions:
10+
// - contains: 'Hello, World!'
11+
// - contains: 'Unmatched: []'
12+
// - command: '{filename} --name World --unknown arg extra'
13+
// assertions:
14+
// - contains: 'Hello, World!'
15+
// - contains: "--unknown"
16+
// - contains: "arg"
17+
// - contains: "extra"
18+
// ---
19+
import cliForge from 'cli-forge';
20+
21+
const cli = cliForge('non-strict-mode-example')
22+
// Strict mode is disabled by default
23+
.strict(false)
24+
.option('name', {
25+
type: 'string',
26+
description: 'The name to greet',
27+
default: 'World',
28+
})
29+
.command('$0', {
30+
builder: (args) => args,
31+
handler: (args) => {
32+
console.log(`Hello, ${args.name}!`);
33+
console.log('Unmatched:', args.unmatched);
34+
},
35+
});
36+
37+
export default cli;
38+
39+
if (require.main === module) {
40+
(async () => {
41+
await cli.forge();
42+
})();
43+
}

examples/strict-mode.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// ---
2+
// id: strict-mode
3+
// title: Strict Mode
4+
// description: |
5+
// This example demonstrates how to use strict mode to ensure that all arguments are recognized.
6+
// Strict mode throws a validation error when unmatched arguments are encountered.
7+
// commands:
8+
// - command: '{filename} --name World'
9+
// assertions:
10+
// - contains: 'Hello, World!'
11+
// - command: '{filename} --name World --unknown arg'
12+
// assertions:
13+
// - contains: 'Unknown argument: --unknown'
14+
// - contains: 'Unknown argument: arg'
15+
// exitCode: 1
16+
// ---
17+
import cliForge from 'cli-forge';
18+
19+
const cli = cliForge('strict-mode-example')
20+
// Enable strict mode - unmatched arguments will throw validation errors
21+
.strict()
22+
.option('name', {
23+
type: 'string',
24+
description: 'The name to greet',
25+
default: 'World',
26+
})
27+
.command('$0', {
28+
builder: (args) => args,
29+
handler: (args) => {
30+
console.log(`Hello, ${args.name}!`);
31+
},
32+
});
33+
34+
export default cli;
35+
36+
if (require.main === module) {
37+
(async () => {
38+
await cli.forge();
39+
})();
40+
}

packages/cli-forge/src/lib/internal-cli.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,4 +343,40 @@ describe('cliForge', () => {
343343
// - 'bar' handler
344344
]);
345345
});
346+
347+
it('should support strict mode', async () => {
348+
const mock = mockConsoleLog();
349+
350+
try {
351+
await cli('test')
352+
.strict()
353+
.option('foo', { type: 'string' })
354+
.forge(['--foo', 'hello', '--unknown', 'arg']);
355+
} catch (e) {
356+
// Expected to throw
357+
}
358+
359+
const output = mock.getOutput();
360+
expect(output).toContain('Unknown argument: --unknown');
361+
expect(output).toContain('Unknown argument: arg');
362+
mock.restore();
363+
});
364+
365+
it('should allow disabling strict mode via .strict(false)', async () => {
366+
let captured: any;
367+
368+
await cli('test')
369+
.strict(false)
370+
.option('foo', { type: 'string' })
371+
.command('$0', {
372+
builder: (args) => args,
373+
handler: (args) => {
374+
captured = args;
375+
},
376+
})
377+
.forge(['--foo', 'hello', '--unknown', 'arg']);
378+
379+
expect(captured.foo).toBe('hello');
380+
expect(captured.unmatched).toEqual(['--unknown', 'arg']);
381+
});
346382
});

packages/cli-forge/src/lib/internal-cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,11 @@ export class InternalCLI<
360360
return this as unknown as CLI<TArgs, THandlerReturn, TChildren, TParent>;
361361
}
362362

363+
strict(enable = true): CLI<TArgs, THandlerReturn, TChildren, TParent> {
364+
this.parser.options.strict = enable;
365+
return this as unknown as CLI<TArgs, THandlerReturn, TChildren, TParent>;
366+
}
367+
363368
usage(usageText: string): CLI<TArgs, THandlerReturn, TChildren, TParent> {
364369
this.configuration ??= {};
365370
this.configuration.usage = usageText;

packages/cli-forge/src/lib/public-api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,15 @@ export interface CLI<
676676
*/
677677
demandCommand(): CLI<TArgs, THandlerReturn, TChildren, TParent>;
678678

679+
/**
680+
* Enables or disables strict mode. When strict mode is enabled, the parser throws a validation error
681+
* when unmatched arguments are encountered. Unmatched arguments are those that don't match any
682+
* configured option or positional argument.
683+
* @param enable Whether to enable strict mode. Defaults to true.
684+
* @returns Updated CLI instance.
685+
*/
686+
strict(enable?: boolean): CLI<TArgs, THandlerReturn, TChildren, TParent>;
687+
679688
/**
680689
* Sets the usage text for the CLI. This text will be displayed in place of the default usage text
681690
* @param usageText Text displayed in place of the default usage text for `--help` and in generated docs.

packages/parser/src/lib/parser.spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,57 @@ describe('parser', () => {
283283
});
284284
});
285285

286+
it('should throw error in strict mode when unmatched arguments are present', () => {
287+
expect(() =>
288+
parser({ strict: true })
289+
.option('foo', { type: 'string' })
290+
.parse(['--foo', 'hello', 'world'])
291+
).toThrowAggregateErrorContaining('Unknown argument: world');
292+
});
293+
294+
it('should throw error in strict mode for unmatched flags', () => {
295+
expect(() =>
296+
parser({ strict: true })
297+
.option('foo', { type: 'string' })
298+
.parse(['--foo', 'hello', '--bar', '42'])
299+
).toThrowAggregateErrorContaining('Unknown argument: --bar', 'Unknown argument: 42');
300+
});
301+
302+
it('should not throw error in strict mode when all arguments are matched', () => {
303+
expect(
304+
parser({ strict: true })
305+
.option('foo', { type: 'string' })
306+
.option('bar', { type: 'number' })
307+
.parse(['--foo', 'hello', '--bar', '42'])
308+
).toEqual({ foo: 'hello', bar: 42, unmatched: [] });
309+
});
310+
311+
it('should allow strict mode to be disabled (default behavior)', () => {
312+
expect(
313+
parser({ strict: false })
314+
.option('foo', { type: 'string' })
315+
.parse(['--foo', 'hello', 'world'])
316+
).toEqual({ foo: 'hello', unmatched: ['world'] });
317+
});
318+
319+
it('should allow enabling strict mode via .strict() method', () => {
320+
expect(() =>
321+
parser()
322+
.option('foo', { type: 'string' })
323+
.strict()
324+
.parse(['--foo', 'hello', 'world'])
325+
).toThrowAggregateErrorContaining('Unknown argument: world');
326+
});
327+
328+
it('should allow disabling strict mode via .strict(false) method', () => {
329+
expect(
330+
parser({ strict: true })
331+
.option('foo', { type: 'string' })
332+
.strict(false)
333+
.parse(['--foo', 'hello', 'world'])
334+
).toEqual({ foo: 'hello', unmatched: ['world'] });
335+
});
336+
286337
it('should have correct types with coerce', () => {
287338
const parsed = parser()
288339
.option('foo', { type: 'string', coerce: (s) => Number(s) })

packages/parser/src/lib/parser.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ export type ParserOptions<T extends ParsedArgs = ParsedArgs> = {
8484
tokens: string[],
8585
parser: ArgvParser<T>
8686
) => boolean;
87+
88+
/**
89+
* When set to true, throws a validation error if any unmatched arguments are encountered.
90+
* Unmatched arguments are those that don't match any configured option or positional argument.
91+
*/
92+
strict?: boolean;
8793
};
8894

8995
export interface ReadonlyArgvParser<TArgs extends ParsedArgs> {
@@ -154,6 +160,7 @@ export class ArgvParser<
154160
this.options = {
155161
extraParsers: {},
156162
unmatchedParser: () => false,
163+
strict: false,
157164
...options,
158165
};
159166
this.parserMap = {
@@ -614,6 +621,17 @@ export class ArgvParser<
614621
}
615622
}
616623

624+
// Validate strict mode - check for unmatched arguments
625+
if (this.options.strict && result.unmatched?.length) {
626+
for (const unmatchedArg of result.unmatched) {
627+
const error = new Error(
628+
`Unknown argument: ${unmatchedArg}`
629+
);
630+
delete error.stack;
631+
errors.push(error);
632+
}
633+
}
634+
617635
if (errors.length) {
618636
const error = new ValidationFailedError<TArgs>(
619637
errors.map((error) =>
@@ -728,6 +746,18 @@ export class ArgvParser<
728746
return this;
729747
}
730748

749+
/**
750+
* Enables or disables strict mode. When strict mode is enabled, the parser throws a validation error
751+
* when unmatched arguments are encountered. Unmatched arguments are those that don't match any
752+
* configured option or positional argument.
753+
* @param enable Whether to enable strict mode. Defaults to true.
754+
* @returns The parser instance for method chaining.
755+
*/
756+
strict(enable = true) {
757+
this.options.strict = enable;
758+
return this;
759+
}
760+
731761
/**
732762
* Used to combine two parsers into a single parser. Mutates `this`, but returns with updated typings
733763
* @param parser The parser to augment the current parser with.

tools/scripts/collect-examples.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type CommandConfiguration = {
1010
command: string;
1111
env: Record<string, string>;
1212
assertions?: Array<{ contains?: string }>;
13+
exitCode?: number;
1314
};
1415

1516
export type FrontMatter = {

0 commit comments

Comments
 (0)