Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 17 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,23 @@ function parseArgs(argv: string[]): ParsedArgs {
const [command, ...rest] = argv;
const flags = new Map<string, string | boolean>();
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);
}
Expand Down Expand Up @@ -70,14 +79,16 @@ async function expandInputs(inputs: string[]): Promise<string[]> {
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<number> {
const [oldPath, newPath] = args.positionals;
if (!oldPath || !newPath) throw new Error('compare requires <old> and <new>.');
if (!oldPath || !newPath || args.positionals.length !== 2) throw new Error('compare requires exactly <old> and <new>.');
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'));
Expand Down
42 changes: 42 additions & 0 deletions tests/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <old> and <new>/);
});

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, '');
});