When command line argument parsing is written by hand, it tends to ignore unknown flags silently and fall through to a default action. This article covers a case where a single command typed as --help ran the real enqueue path and added 21 items to a queue. The cause is not an unusual coding mistake; it is built into the shape of a chain of args.includes() calls.
What happened
A script was invoked with --help to check its usage. No help appeared; the normal enqueue path ran instead. The queue went from 612 to 633 entries. The unknown flag was ignored and execution fell back to the default action.
Three things make this serious. First, a queue write is picked up later by a scheduled job that sends the content outward. Second, the entries carried template text that would have gone out unnoticed. Third, exploratory runs are unsafe: a misspelled --dry-run produces the same outcome.
Why it happens
The cause is code of this shape. Chained ternaries read well, but the destination for input that matches nothing is an action with side effects.
// Before. Both --hepl and --help land here const args = process.argv.slice(2); const run = args.includes('--plan') ? plan() : args.includes('--enqueue') ? enqueue({ file: value('--file') }) : generate(); // default: writes to the queue
This shape cannot express "do nothing when no mode is given". includes returns only a boolean, so it never holds which of the passed arguments were not understood. A mistyped flag is indistinguishable from an omitted one and lands in the default branch.
What the standard parsers do
Standard library parsers reject unknown options before anything runs. Measured on August 26, 2026.
$ node -e "const {parseArgs}=require('node:util'); parseArgs({args:['--hepl'],options:{help:{type:'boolean'}}})" ERR_PARSE_ARGS_UNKNOWN_OPTION | Unknown option '--hepl' $ node -e "... parseArgs({args:['--hepl'],options:{...},strict:false})" {"values":{"hepl":true},"positionals":[]} $ python3 -c "import argparse; p=argparse.ArgumentParser(prog='demo'); p.add_argument('--generate',action='store_true'); p.parse_args(['--hepl'])" usage: demo [-h] [--generate] demo: error: unrecognized arguments: --hepl (exit code 2)
| Implementation | Unknown flag | Result |
|---|---|---|
node:util parseArgs (default) | --hepl | throws ERR_PARSE_ARGS_UNKNOWN_OPTION |
node:util parseArgs (strict:false) | --hepl | passes through as values.hepl = true |
Python argparse | --hepl | prints usage, exits with code 2 |
Hand-rolled args.includes() chain | --hepl | runs the default branch |
Exit code 2 is not arbitrary. By convention it means "nothing ran; the invocation was wrong". Returning 0 makes the mistake indistinguishable from success when called from a script, and returning 1 mixes it with failures during processing.
The fix
Three changes.
- Declare the flags as lists. Separate modes, options that take a value, and boolean flags; anything outside those lists is collected as unknown.
- Have no default mode. When zero modes are given, exit with code 2 without running anything. Keeping "the old behaviour for no arguments" for compatibility keeps the dangerous path alive. If it must stay, move it behind an explicit flag such as
--generate. - Give help no side effects. Implement
--helpand-h, print, and exit.
const MODE_FLAGS = ['--plan', '--enqueue', '--generate'];
const OPTION_FLAGS = ['--file'];
const BOOLEAN_FLAGS = ['--dry', '--dry-run', '--help', '-h'];
function parseArgs(args) {
const unknown = [];
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (MODE_FLAGS.includes(a) || BOOLEAN_FLAGS.includes(a)) continue;
if (OPTION_FLAGS.includes(a)) { i++; continue; } // the next item is a value
unknown.push(a);
}
return { unknown, modes: MODE_FLAGS.filter(f => args.includes(f)) };
}
const parsed = parseArgs(process.argv.slice(2));
if (parsed.help) { console.log(USAGE); process.exit(0); }
if (parsed.unknown.length) { console.error('unknown argument: ' + parsed.unknown.join(' ')); process.exit(2); }
if (parsed.modes.length !== 1) { console.error(USAGE); process.exit(2); }
Do not copy the same logic into several scripts. In the real case this accident recurred in a sibling script after being fixed once elsewhere. Put the parsing in one module and pass only the flag lists, so a half-fixed state cannot exist.
Do not force the same meaning on every flag
One caveat. The same spelling can mean different things in different scripts. In this case --test meant "run without writing" in one script and "actually send exactly one item as a final check" in another. Treating --test as dry everywhere during consolidation makes a run that was meant to send silently do nothing, or the reverse. Share the parsing mechanism only, and let each caller declare which flags count as dry.
Writing the regression test
A unit test on the parser is not enough. Even with correct parsing, the accident reproduces if the caller still falls back to the default. Start a real process and assert both the exit code and the side effects.
const before = queueLength();
for (const args of [['--hepl'], [], ['--dry']]) {
const r = runCli('cli.js', args);
assert.strictEqual(r.code, 2, args.join(' ') + ' should exit 2');
}
assert.strictEqual(runCli('cli.js', ['--help']).code, 0);
assert.strictEqual(queueLength(), before, 'the queue length must not change');
Checking the count also catches the half-done implementation that exits with 2 after already writing one entry. A test that looks only at the exit code lets that through.
Checklist
- Run it with no arguments. Does anything get written? If so, there is a default mode.
- Pass a flag that does not exist. What is the exit code? If it is 0, the flag was ignored.
- Is
--helpimplemented? Without it, checking the usage is itself a run of the default action. - Does the same flag spelling mean something different in a sibling script? List them before consolidating.
How to reproduce
- Save the "before" code as
cli.jsand runnode cli.js --hepl; the default branch runs - Run
node -e "const {parseArgs}=require('node:util'); parseArgs({args:['--hepl'],options:{help:{type:'boolean'}}})"and read the exceptioncode - Add
strict:falseto the same call and confirm no exception is thrown andvalues.heplappears - Measured on August 26, 2026 / Node.js v20.17.0 / Python 3
FAQ
Why is a default action on no arguments dangerous?
Because when the default action has side effects, a typo or an exploratory run becomes a production run. One command typed as --help or --dry-run can reach a queue write or an outbound send. Removing the default mode and requiring an explicit mode removes that path entirely.
How should unknown arguments be handled?
Exit with code 2 without running anything. Continuing while ignoring them makes a mistyped flag equivalent to not passing it, so the unintended default behaviour runs. A non-zero exit code also surfaces the mistake when the command is called from a script.
Does Node.js parseArgs reject unknown arguments by default?
Yes. In node:util, strict defaults to true, so an unknown option throws ERR_PARSE_ARGS_UNKNOWN_OPTION. Setting strict to false stops the exception and lets the flag through as a value. Measured on Node.js v20.17.0 on August 26, 2026.
What should the regression test check?
Both the exit code and the side effects. A unit test on the parser alone misses the case where parsing is correct but the caller still falls back to the default action. Start a real process, assert the exit code is 2, and assert that the queue or file count did not change.