From 8f4a80eec597032b4c9f0d55607d8d594dbef83b Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Sun, 2 Aug 2026 22:39:08 +1000 Subject: [PATCH 1/3] test: cover strict CLI argument validation --- tests/cli.test.mjs | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 888f9f6..793baf7 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -26,3 +26,45 @@ test('cli check explains unmatched globs', () => { assert.equal(run.status, 1); assert.match(run.stderr, /did not match any files/); }); + +test('cli rejects unknown options', () => { + const run = spawnSync(process.execPath, ['dist/cli.js', 'compare', 'examples/prompts/v1.md', 'examples/prompts/v2.md', '--bogus'], { encoding: 'utf8' }); + assert.equal(run.status, 1); + assert.equal(run.stdout, ''); + assert.match(run.stderr, /Unknown option: --bogus/); +}); + +test('cli rejects options without values', () => { + const run = spawnSync(process.execPath, ['dist/cli.js', 'check', 'examples/prompts/safe.md', '--rules'], { encoding: 'utf8' }); + assert.equal(run.status, 1); + assert.equal(run.stdout, ''); + assert.match(run.stderr, /Option --rules requires a value/); +}); + +test('cli compare rejects extra positional arguments', () => { + const run = spawnSync(process.execPath, ['dist/cli.js', 'compare', 'examples/prompts/v1.md', 'examples/prompts/v2.md', 'extra.md'], { encoding: 'utf8' }); + assert.equal(run.status, 1); + assert.equal(run.stdout, ''); + assert.match(run.stderr, /compare requires exactly and /); +}); + +test('cli check rejects a mixed matched and unmatched input set', () => { + const run = spawnSync(process.execPath, ['dist/cli.js', 'check', 'examples/prompts/safe.md', 'examples/prompts/missing-*.md', '--rules', 'examples/rules.json'], { encoding: 'utf8' }); + assert.equal(run.status, 1); + assert.equal(run.stdout, ''); + assert.match(run.stderr, /input did not match any files: examples\/prompts\/missing-\*\.md/); +}); + +test('cli accepts documented compare option forms', () => { + const run = spawnSync(process.execPath, ['dist/cli.js', 'compare', 'examples/prompts/v1.md', 'examples/prompts/v2.md', '--format', 'json', '--fail-on', 'high', '--no-redact'], { encoding: 'utf8' }); + assert.equal(run.status, 2); + assert.doesNotThrow(() => JSON.parse(run.stdout)); + assert.equal(run.stderr, ''); +}); + +test('cli accepts documented check option forms', () => { + const run = spawnSync(process.execPath, ['dist/cli.js', 'check', 'examples/prompts/safe.md', '--rules', 'examples/rules.json', '--format=markdown', '--fail-on=high', '--no-redact'], { encoding: 'utf8' }); + assert.equal(run.status, 0); + assert.match(run.stdout, /PromptDiff Rules Check/); + assert.equal(run.stderr, ''); +}); From 6e362a0fa697a7ac89940bbf2f0631a2668b12ac Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Sun, 2 Aug 2026 22:39:46 +1000 Subject: [PATCH 2/3] fix: enforce strict CLI arguments --- src/cli.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 31c080c..fdd051d 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,14 +19,23 @@ function parseArgs(argv: string[]): ParsedArgs { const [command, ...rest] = argv; const flags = new Map(); const positionals: string[] = []; + const valueOptions = new Set(['format', 'out', 'fail-on', ...(command === 'check' ? ['rules'] : [])]); + const booleanOptions = new Set(['no-redact']); for (let i = 0; i < rest.length; i += 1) { const arg = rest[i]; if (arg.startsWith('--')) { const [key, inline] = arg.slice(2).split('=', 2); - if (key === 'no-redact') flags.set('redact', false); - else if (inline !== undefined) flags.set(key, inline); - else if (rest[i + 1] && !rest[i + 1].startsWith('--')) flags.set(key, rest[++i]); - else flags.set(key, true); + if (!valueOptions.has(key) && !booleanOptions.has(key)) throw new Error(`Unknown option: --${key}`); + if (booleanOptions.has(key)) { + if (inline !== undefined) throw new Error(`Option --${key} does not take a value.`); + flags.set('redact', false); + } else if (inline !== undefined && inline !== '') { + flags.set(key, inline); + } else if (inline === undefined && rest[i + 1] && !rest[i + 1].startsWith('--')) { + flags.set(key, rest[++i]); + } else { + throw new Error(`Option --${key} requires a value.`); + } } else { positionals.push(arg); } @@ -70,14 +79,16 @@ async function expandInputs(inputs: string[]): Promise { const pattern = input.slice(dir.length + 1).replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); const regex = new RegExp(`^${pattern}$`); const entries = await readdir(dir === '.' ? process.cwd() : dir); - expanded.push(...entries.filter((entry) => regex.test(entry)).map((entry) => join(dir, entry)).sort()); + const matches = entries.filter((entry) => regex.test(entry)).map((entry) => join(dir, entry)).sort(); + if (matches.length === 0) throw new Error(`check input did not match any files: ${input}`); + expanded.push(...matches); } return [...new Set(expanded)].sort(); } async function runCompare(args: ParsedArgs): Promise { const [oldPath, newPath] = args.positionals; - if (!oldPath || !newPath) throw new Error('compare requires and .'); + if (!oldPath || !newPath || args.positionals.length !== 2) throw new Error('compare requires exactly and .'); const redact = args.flags.get('redact') !== false; const [oldPrompt, newPrompt] = await Promise.all([readPrompt(oldPath, redact), readPrompt(newPath, redact)]); const failOn = parseSeverity(flagString(args.flags, 'fail-on')); From 3d540ef687130b3ab8c8c4513fa00deec8d80734 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Sun, 2 Aug 2026 22:40:05 +1000 Subject: [PATCH 3/3] docs: document strict CLI error behavior --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a6453be..a525a4b 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,9 @@ Checks one or more prompt files against a simple JSON rules file. promptdiff check prompts/*.md --rules promptdiff.rules.json --fail-on high ``` -Exit code `2` means the configured quality gate failed. Exit code `1` means a command/runtime error. +Both commands parse options strictly. Unknown options and options missing their required values are errors, and `compare` accepts exactly two file arguments. For `check`, every file or glob supplied on the command line must match; PromptDiff does not emit a partial report when one input is unmatched. + +Exit code `0` means the command completed without tripping a gate. Exit code `2` means the configured quality gate failed. Exit code `1` means invalid command input or another runtime error; diagnostics are written to stderr. ## Supported inputs