--help を叩いたら本番投入が走った — 未知の引数を既定動作へ落とさない

コマンドラインツールの引数解釈を自分で書くと、知らないフラグを黙って無視して既定の動作へ落ちる形になりがちです。--help のつもりで打った1回が本番の投入処理になり、キューに21件積まれた事例を扱います。原因は例外的な実装ミスではなく、args.includes() を並べる書き方そのものに構造的に含まれています。

この記事の実測について: 以下の出力は 2026年8月26日Node.js v20.17.0Python 3 で実際に実行して得たものです。コードは記事内に全文を載せてあり、そのまま再現できます。

何が起きたか

投入用のスクリプトに使い方の確認として --help を渡したところ、ヘルプは表示されず通常の投入処理がそのまま走りました。キューの件数は 612 から 633 へ増えています。未知のフラグが無視され、既定の動作へフォールバックした結果です。

問題は3点あります。第一に、キューへの書き込みは後段の定期処理が拾って外部へ送信するところまで届きます。第二に、積まれた内容は定型文で、気づかなければそのまま流れていました。第三に、調査目的の実行が安全でないことです。--dry-run のつもりで綴りを間違えれば、同じことが起きます。

なぜ起きるのか

原因は次のような書き方です。三項演算子で並べると読みやすく見えますが、どの条件にも当たらなかったときの行き先が「副作用のある処理」になっています

// 修正前。--hepl と打っても、--help と打っても、ここへ落ちる
const args = process.argv.slice(2);
const run = args.includes('--plan')
  ? plan()
  : args.includes('--enqueue')
    ? enqueue({ file: value('--file') })
    : generate();   // ← 既定。キューへ書き込む

この形は「指定が無いときに何もしない」ようには書けません。includes が返すのは真偽値だけで、渡された引数のうち解釈できなかったものが何であったかを持っていないためです。打ち間違えたフラグは「指定しなかった」のと区別が付かず、既定の枝へ入ります。

標準のパーサはどうしているか

言語標準の引数パーサは、いずれも未知のオプションを実行前に拒否します。次は 2026年8月26日の実測です。

$ 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
(終了コード 2)
実装未知のフラグ結果
node:util parseArgs(既定)--hepl例外 ERR_PARSE_ARGS_UNKNOWN_OPTION
node:util parseArgs(strict:false--heplvalues.hepl = true として素通り
Python argparse--heplusage を表示して終了コード 2
自前の args.includes() 連鎖--hepl既定の枝を実行

終了コード2 が使われているのは偶然ではありません。 「実行はしていないが、呼び出し方が誤っている」を表す慣習的な値です。0 で返すとスクリプトから呼んだときに成功と区別が付かず、1 で返すと処理中の失敗と混ざります。

直し方

やることは3つです。

  1. フラグを一覧として宣言する。 モード・値を取るオプション・真偽フラグの3種類に分け、そこに無いものを未知として集めます。
  2. 既定モードを持たない。 モードが0件なら実行せずに終了コード2で落とします。「引数なしのときの従来動作」を互換のために残すと、この経路が生き残ります。残すなら --generate のように明示のフラグへ移します。
  3. ヘルプに副作用を持たせない。 --help-h を実装し、表示して終了します。
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; }   // 次の要素は値
    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('未知の引数: ' + parsed.unknown.join(' ')); process.exit(2); }
if (parsed.modes.length !== 1) { console.error(USAGE); process.exit(2); }

同じ判定を複数のスクリプトに書き写さないでください。 実際にこの事故は、同じ組織の別スクリプトで一度直したあと、直していないほうで再発しています。解釈は1つのモジュールに置き、フラグの一覧だけを渡す形にすると、片方だけ直した状態を作れません。

フラグごとの意味を揃えない

1点だけ注意があります。同じ綴りのフラグが、スクリプトによって違う意味を持つことがあります。 今回の例では --test が、片方では「書き込みをしない試験」、もう片方では「実際に1本だけ送信する完成検証」でした。共通化するときに --test を一律で dry 扱いにすると、送信するつもりの実行が黙って何もしなくなる、あるいはその逆が起きます。共通化するのは解釈の仕組みだけにして、どのフラグを dry と見なすかは呼び出し側で宣言します。

Tool 例示用IPアドレス・ドメイン生成 手順書に貼るコマンド例の宛先を、規格が予約した「どこにも届かない値」から作れます。読者がコピーして実行しても、無関係のホストへ通信が飛びません。ブラウザ内で完結します。

回帰テストの書き方

引数解釈の単体テストだけでは足りません。 解釈が正しくても、呼び出し側が既定動作へ落ちていれば事故は再現します。実プロセスを起動して、終了コードと副作用の両方を検査します。

const before = queueLength();
for (const args of [['--hepl'], [], ['--dry']]) {
  const r = runCli('cli.js', args);
  assert.strictEqual(r.code, 2, args.join(' ') + ' は exit 2 であるべき');
}
assert.strictEqual(runCli('cli.js', ['--help']).code, 0);
assert.strictEqual(queueLength(), before, 'キューの件数が変わってはいけない');

件数の検査を入れておくと、「終了コードは2だが、その前に1件書いていた」という中途半端な実装も落とせます。終了コードだけを見るテストは、この形を通してしまいます。

チェックリスト

  1. 引数を1つも渡さずに実行したとき、何か書き込まれるか。 書き込まれるなら既定モードがあります。
  2. 存在しないフラグを渡したとき、終了コードは何か。 0 なら無視されています。
  3. --help は実装されているか。 未実装だと、使い方を確かめる操作が既定動作の実行になります。
  4. 同じ綴りのフラグが、別のスクリプトで別の意味になっていないか。 共通化の前に洗い出します。

再現手順

  • 本文の「修正前」のコードを cli.js として保存し、node cli.js --hepl を実行する(既定の枝が走る)
  • node -e "const {parseArgs}=require('node:util'); parseArgs({args:['--hepl'],options:{help:{type:'boolean'}}})" を実行し、例外の code を確認する
  • 同じ呼び出しに strict:false を足し、例外にならず values.hepl が生えることを確認する
  • 実測日: 2026年8月26日 / Node.js v20.17.0 / Python 3

よくある質問(FAQ)

なぜ「引数なしで既定動作」が危ないのですか?

既定動作に副作用があると、打ち間違いと調査目的の実行が本番実行に変わるためです。--help や --dry-run のつもりで打った1回が、キューへの書き込みや外部への送信まで到達します。既定モードを持たず、モードの明示を必須にすれば、この経路そのものが無くなります。

未知の引数はどう扱うのが良いですか?

実行せずに終了コード2で落とすのが安全です。無視して続行すると、打ち間違えたフラグが「指定しなかった」のと同じ扱いになり、意図と違う既定の挙動が走ります。終了コードを0以外にしておくと、スクリプトから呼んだときにも気づけます。

Node.js の parseArgs は既定で未知の引数を弾きますか?

弾きます。node:util の parseArgs は strict が既定で true なので、未知のオプションを渡すと ERR_PARSE_ARGS_UNKNOWN_OPTION の例外になります。strict を false にすると例外にならず、値として素通りします。2026年8月26日に Node.js v20.17.0 で実測しました。

回帰テストは何を検査すればよいですか?

終了コードと副作用の両方です。引数解釈の単体テストだけだと、解釈は正しいのに呼び出し側が既定動作へ落ちている場合を見逃します。実プロセスを起動して、終了コードが2であることと、キューやファイルの件数が変わっていないことを合わせて検査します。

← 技術ブログ一覧へ戻る