From eafb4e1b06e7a75e84b0526cbb30ee4e8eca5140 Mon Sep 17 00:00:00 2001 From: yearthmain Date: Tue, 4 Aug 2026 13:56:58 +0800 Subject: [PATCH] fix(cli): reject conflicting output modes --- src/commands/root.ts | 99 ++++++++++++++++++++++++++++++++++---------- test/cli.test.ts | 54 ++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 23 deletions(-) diff --git a/src/commands/root.ts b/src/commands/root.ts index 7043fd3..8bbfb19 100644 --- a/src/commands/root.ts +++ b/src/commands/root.ts @@ -647,30 +647,79 @@ export async function root(argv: string[]) { budget: num(flags.budget, 0, { flag: '--budget', kind: 'positive integer', fail }), offset: num(flags.offset, 0, { flag: '--offset', kind: 'non-negative integer', fail }), } - const envelopeModifiers = [ - ['--attr', flags.attr], - ['--row', flags.row], - ['--locate', flags.locate], - ['--where', flags.where], - ] as const - const jsonEnvelope = flags['json-envelope'] === true const optionsEnd = argv.indexOf('--') - const missingEnvelopeValue = - (jsonEnvelope ? envelopeModifiers.find(([, value]) => value === true)?.[0] : undefined) ?? - envelopeModifiers.find(([flag]) => - argv.some( - (arg, index) => - (optionsEnd === -1 || index < optionsEnd) && - arg === '--json-envelope' && - argv[index - 1] === flag - ) - )?.[0] - if (missingEnvelopeValue) { + const optionArgs = argv.slice(0, optionsEnd === -1 ? argv.length : optionsEnd) + const outputValueFlags = [ + ['--attr', ['--attr']], + ['--row', ['--row']], + ['--locate', ['--locate']], + ['--where', ['--where']], + ['--output', ['--output', '-o']], + ] as const + const outputFlagTokens = new Set([ + ...outputValueFlags.flatMap(([, spellings]) => spellings), + '--md', + '--outline', + '--table', + '--count', + '--text', + '--html', + '--body', + '--tsv', + '--json', + '--json-envelope', + ]) + const isOutputFlagToken = (arg: string) => + outputFlagTokens.has(arg) || + [...outputFlagTokens].some((flag) => + flag.startsWith('--') + ? arg.startsWith(`${flag}=`) + : arg.startsWith(flag) && arg.length > flag.length + ) + const missingOutputValue = outputValueFlags.find(([, spellings]) => + optionArgs.some( + (arg, index) => + spellings.some((spelling) => spelling === arg) && + (optionArgs[index + 1] === undefined || isOutputFlagToken(optionArgs[index + 1]!)) + ) + )?.[0] + if (missingOutputValue) fail(`${missingOutputValue} requires a value`) + + const outputModes = [ + ['--md', flags.md === true], + ['--outline', flags.outline === true], + ['--locate', typeof flags.locate === 'string'], + ['--table', flags.table === true], + ['--count', flags.count === true], + ['--row', typeof flags.row === 'string'], + ['--text', flags.text === true], + ['--attr', typeof flags.attr === 'string'], + ['--html', flags.html === true], + ['--output', typeof flags.output === 'string'], + ['--body', flags.body === true], + ['--tsv', flags.tsv === true], + ['--json', flags.json === true], + ] as const + const activeOutputModes = outputModes.filter(([, active]) => active).map(([flag]) => flag) + const formatModifierOnly = + activeOutputModes.length === 2 && + ((activeOutputModes.includes('--json') && + activeOutputModes.some((flag) => ['--locate', '--table', '--row'].includes(flag))) || + (activeOutputModes.includes('--tsv') && + activeOutputModes.some((flag) => ['--table', '--row'].includes(flag)))) + if (activeOutputModes.length > 1 && !formatModifierOnly) { fail( - `${missingEnvelopeValue} requires a value`, - 'pass the modifier value before --json-envelope' + `conflicting output modes: ${activeOutputModes.join(', ')}`, + 'choose one output mode; --json may modify --row, --table, or --locate' ) } + const jsonEnvelope = flags['json-envelope'] === true + if (flags.json === true && jsonEnvelope) { + fail('conflicting output modes: --json, --json-envelope', 'choose one JSON output format') + } + if (typeof flags.where === 'string' && typeof flags.row !== 'string' && flags.table !== true) { + fail('--where requires --row or --table') + } const emitStructured = (value: unknown[]) => jsonEnvelope ? emitJsonEnvelope(value, opts) : emitJson(value, opts) const isUrl = /^https?:\/\//.test(src!) @@ -687,9 +736,13 @@ export async function root(argv: string[]) { ? '--html' : typeof flags.attr === 'string' ? '--attr' - : flags.body === true - ? '--body' - : null + : typeof flags.output === 'string' + ? '--output' + : flags.body === true + ? '--body' + : flags.tsv === true + ? '--tsv' + : null if (jsonEnvelope && envelopeConflict) { const hint = envelopeConflict === '--md' diff --git a/test/cli.test.ts b/test/cli.test.ts index 136e34a..8c435ae 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -48,6 +48,7 @@ test('extract: --row defaults to TSV, --json for JSON rows', () => { const tsv = ax(['page.html', '.card', '--row', 'title=a, href=a@href, lv=.lv']).out expect(tsv.split('\n')[0]).toBe('title\thref\tlv') expect(tsv.split('\n')[1]).toBe('One bold\t/1.htm\tA1') + expect(ax(['page.html', '.card', '--row', 'title=a, href=a@href, lv=.lv', '--tsv']).out).toBe(tsv) const rows = JSON.parse( ax(['page.html', '.card', '--row', 'title=a, href=a@href, lv=.lv', '--json']).out ) @@ -538,6 +539,57 @@ test('numeric output flags preserve valid boundary values', () => { expect(budget.out).toBe('One boldA1') }) +test('conflicting output modes fail before source I/O', () => { + const cases = [ + ['--md', '--outline'], + ['--row', 'title=a', '--table'], + ['--count', '--html'], + ['--attr', 'href', '--json'], + ['--json', '--json-envelope'], + ['-o', 'out.bin', '--body'], + ['--row', 'title=a', '--json', '--tsv'], + ] + + for (const flags of cases) { + const r = ax(['missing.html', '.x', ...flags]) + expect(r.code).toBe(1) + expect(r.out).toBe('') + expect(r.err).toContain('ax: error: conflicting output modes:') + expect(r.err).not.toContain('ENOENT') + } +}) + +test('output string flags do not swallow a conflicting flag as their value', () => { + const cases: [string[], string][] = [ + [['--attr', '--html'], '--attr'], + [['--row', '--table'], '--row'], + [['--locate', '--count'], '--locate'], + [['-o', '--body'], '--output'], + [['--row', '--json'], '--row'], + [['--attr', '--row=title=a'], '--attr'], + [['--row', '--attr=href'], '--row'], + [['--output', '--row=title=a'], '--output'], + [['--row', '-oout.bin'], '--row'], + [['--row', '-o=out.bin'], '--row'], + ] + + for (const [flags, missing] of cases) { + const r = ax(['missing.html', '.x', ...flags]) + expect(r.code).toBe(1) + expect(r.out).toBe('') + expect(r.err).toContain(`ax: error: ${missing} requires a value`) + expect(r.err).not.toContain('ENOENT') + } +}) + +test('--where fails outside --row or --table before source I/O', () => { + const r = ax(['missing.html', '.x', '--where', 'name == "x"']) + expect(r.code).toBe(1) + expect(r.out).toBe('') + expect(r.err).toContain('ax: error: --where requires --row or --table') + expect(r.err).not.toContain('ENOENT') +}) + test('cap: default limit with stderr note', () => { const r = ax(['many.html', '.x']) expect(r.out.split('\n')).toHaveLength(50) @@ -703,7 +755,9 @@ test('json envelope: unsupported modes fail before reading the source', () => { ['missing.html', '.x', '--text', '--json-envelope'], ['missing.html', '.x', '--html', '--json-envelope'], ['missing.html', '.x', '--attr', 'href', '--json-envelope'], + ['missing.html', '.x', '--output', 'out.bin', '--json-envelope'], ['missing.html', '.x', '--body', '--json-envelope'], + ['missing.html', '.x', '--row', 'title=a', '--tsv', '--json-envelope'], ] for (const args of cases) {