diff --git a/package-lock.json b/package-lock.json index 9405a71..05258da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@autor3search/javascript", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@autor3search/javascript", - "version": "0.2.2", + "version": "0.2.3", "license": "MIT", "dependencies": { "@babel/parser": "^7.25.0", diff --git a/package.json b/package.json index 2a5354d..2c2136a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@autor3search/javascript", - "version": "0.2.2", + "version": "0.2.3", "description": "Autonomous AI-driven performance optimization for any JavaScript repository", "type": "module", "license": "MIT", diff --git a/src/cli/cmd-baseline.js b/src/cli/cmd-baseline.js index 0047ccb..8155f50 100644 --- a/src/cli/cmd-baseline.js +++ b/src/cli/cmd-baseline.js @@ -28,13 +28,14 @@ import { import { expandSingleDashFlags, loadRepoConfig, resolveRepo } from './context.js' export async function runBaseline(args, io) { + const options = { + C: { type: 'string', default: '.' }, + tag: { type: 'string', default: defaultTag() }, + force: { type: 'boolean', default: false }, + } const { values } = parseArgs({ - args: expandSingleDashFlags(args, ['tag']), - options: { - C: { type: 'string', default: '.' }, - tag: { type: 'string', default: defaultTag() }, - force: { type: 'boolean', default: false }, - }, + args: expandSingleDashFlags(args, options), + options, allowPositionals: false, }) diff --git a/src/cli/cmd-eval.js b/src/cli/cmd-eval.js index f9dedd1..88f94aa 100644 --- a/src/cli/cmd-eval.js +++ b/src/cli/cmd-eval.js @@ -29,14 +29,15 @@ const FORCE_POLL_MS = 500 * @returns {Promise} */ export async function runEval(args, io) { + const options = { + C: { type: 'string', default: '.' }, + json: { type: 'boolean', default: false }, + desc: { type: 'string', default: '' }, + 'no-log': { type: 'boolean', default: false }, + } const { values } = parseArgs({ - args: expandSingleDashFlags(args, ['desc']), - options: { - C: { type: 'string', default: '.' }, - json: { type: 'boolean', default: false }, - desc: { type: 'string', default: '' }, - 'no-log': { type: 'boolean', default: false }, - }, + args: expandSingleDashFlags(args, options), + options, allowPositionals: false, }) diff --git a/src/cli/cmd-status.js b/src/cli/cmd-status.js index bd076b3..2fa0571 100644 --- a/src/cli/cmd-status.js +++ b/src/cli/cmd-status.js @@ -17,9 +17,10 @@ import { expandSingleDashFlags, resolveRun } from './context.js' * @returns {Promise} */ export async function runStatus(args, io) { + const options = { C: { type: 'string', default: '.' }, tag: { type: 'string' } } const { values } = parseArgs({ - args: expandSingleDashFlags(args, ['tag']), - options: { C: { type: 'string', default: '.' }, tag: { type: 'string' } }, + args: expandSingleDashFlags(args, options), + options, allowPositionals: false, }) diff --git a/src/cli/cmd-stop.js b/src/cli/cmd-stop.js index a4ec500..f741588 100644 --- a/src/cli/cmd-stop.js +++ b/src/cli/cmd-stop.js @@ -23,14 +23,15 @@ import { expandSingleDashFlags, resolveRun } from './context.js' * @returns {Promise} */ export async function runStop(args, io) { + const options = { + C: { type: 'string', default: '.' }, + tag: { type: 'string' }, + clear: { type: 'boolean', default: false }, + force: { type: 'boolean', default: false }, + } const { values } = parseArgs({ - args: expandSingleDashFlags(args, ['tag']), - options: { - C: { type: 'string', default: '.' }, - tag: { type: 'string' }, - clear: { type: 'boolean', default: false }, - force: { type: 'boolean', default: false }, - }, + args: expandSingleDashFlags(args, options), + options, allowPositionals: false, }) diff --git a/src/cli/context.js b/src/cli/context.js index c92b0f5..3b7ff5e 100644 --- a/src/cli/context.js +++ b/src/cli/context.js @@ -24,20 +24,37 @@ import { join } from 'node:path' * Only an exact whole-token match is rewritten, so a VALUE that happens to * look like a flag (`-desc "-tag is confusing"`) is left alone. * + * The parseArgs OPTIONS are the input, not a list of names, and that is + * load-bearing. A hand-kept list is free to fall out of step with the + * declaration beside it, and did: `stop` accepted `-tag` but not the `-force` + * the README documents, so `stop -force` failed with "Unknown option '-f'" + * until this was derived instead. Deriving it means a flag cannot be + * documented, declared, and still unparseable. + * + * Whether a flag consumes the token after it comes from its declared `type`. + * A boolean takes no value, so the next token is another flag and must still + * be expanded — skipping it is what made `-force -tag t` die on "Unknown + * option '-t'". Single-character names are left alone because parseArgs + * already accepts them in single-dash form; `-C` must stay `-C`. + * * @param {string[]} args - * @param {string[]} names long option names to accept in single-dash form + * @param {Record} options the same parseArgs options the caller parses with * @returns {string[]} */ -export function expandSingleDashFlags(args, names) { - const single = new Set(names.map((name) => `-${name}`)) +export function expandSingleDashFlags(args, options) { + const expandable = new Map( + Object.entries(options) + .filter(([name]) => name.length > 1) + .map(([name, opt]) => [`-${name}`, opt.type !== 'boolean']), + ) let expectingValue = false return args.map((token) => { if (expectingValue) { expectingValue = false return token } - if (single.has(token)) { - expectingValue = true + if (expandable.has(token)) { + expectingValue = expandable.get(token) return `--${token.slice(1)}` } return token diff --git a/test/cli/context.test.js b/test/cli/context.test.js index c9f9715..6fb3900 100644 --- a/test/cli/context.test.js +++ b/test/cli/context.test.js @@ -84,20 +84,48 @@ describe('loadRepoConfig', () => { }) describe('expandSingleDashFlags', () => { + // The real `stop` declaration: one string option, two booleans, and the + // single-character -C. Passing the parseArgs options themselves is the + // point — a hand-kept list of names is what let -force go unexpanded. + const STOP_OPTIONS = { + C: { type: 'string' }, + tag: { type: 'string' }, + clear: { type: 'boolean' }, + force: { type: 'boolean' }, + } + it('rewrites a documented single-dash flag into the form parseArgs accepts', () => { - expect(expandSingleDashFlags(['-tag', 'sep7'], ['tag'])).toEqual(['--tag', 'sep7']) + expect(expandSingleDashFlags(['-tag', 'sep7'], STOP_OPTIONS)).toEqual(['--tag', 'sep7']) }) it('leaves single-character flags and long forms alone', () => { - expect(expandSingleDashFlags(['-C', '/tmp', '--tag', 'x'], ['tag'])).toEqual(['-C', '/tmp', '--tag', 'x']) + expect(expandSingleDashFlags(['-C', '/tmp', '--tag', 'x'], STOP_OPTIONS)).toEqual(['-C', '/tmp', '--tag', 'x']) }) it('does not rewrite a VALUE that looks like a flag', () => { // `-desc "-tag is confusing"` must keep its value verbatim. - expect(expandSingleDashFlags(['-desc', '-tag'], ['desc', 'tag'])).toEqual(['--desc', '-tag']) + const options = { desc: { type: 'string' }, tag: { type: 'string' } } + expect(expandSingleDashFlags(['-desc', '-tag'], options)).toEqual(['--desc', '-tag']) }) it('leaves an unrelated token untouched', () => { - expect(expandSingleDashFlags(['-nope', 'x'], ['tag'])).toEqual(['-nope', 'x']) + expect(expandSingleDashFlags(['-nope', 'x'], STOP_OPTIONS)).toEqual(['-nope', 'x']) + }) + + it('rewrites a single-dash BOOLEAN flag, which README documents for stop', () => { + expect(expandSingleDashFlags(['-force'], STOP_OPTIONS)).toEqual(['--force']) + expect(expandSingleDashFlags(['-clear'], STOP_OPTIONS)).toEqual(['--clear']) + }) + + it('does not swallow the token after a boolean flag', () => { + // A boolean takes no value, so the token after it is another flag and must + // still be expanded. Treating it as a value is what made the first attempt + // at this fix wrong: `-force -tag t` became `['--force', '-tag', 't']` and + // died on "Unknown option '-t'" — one bug traded for a subtler one. + expect(expandSingleDashFlags(['-force', '-tag', 't'], STOP_OPTIONS)).toEqual(['--force', '--tag', 't']) + }) + + it('still treats a string option as taking a value, in either order', () => { + expect(expandSingleDashFlags(['-tag', 't', '-force'], STOP_OPTIONS)).toEqual(['--tag', 't', '--force']) }) }) diff --git a/test/cli/stop.test.js b/test/cli/stop.test.js index 60840e6..6859973 100644 --- a/test/cli/stop.test.js +++ b/test/cli/stop.test.js @@ -57,6 +57,28 @@ describe('stop', () => { expect(await stopRequested(await stateDir(dir, 't'))).toBe(true) }) + it('accepts the single-dash -force the README documents', async () => { + // README's agent prompt tells the human "I may run `stop -force` myself". + // It used to fail with "Unknown option '-f'": parseArgs only takes a single + // dash for a single-character name, and -force was never expanded. The + // unit test in context.test.js covers the expansion; this covers the + // spelling a human actually types. + const dir = await ready() + const { code } = await runCli(['stop', '-C', dir, '-force']) + expect(code).toBe(0) + expect(await forceRequested(await stateDir(dir, 't'))).toBe(true) + }) + + it('accepts -force and -tag together, in either order', async () => { + // A boolean consumes no value, so the flag after it must still be expanded. + const dir = await ready() + expect((await runCli(['stop', '-C', dir, '-force', '-tag', 't'])).code).toBe(0) + expect(await forceRequested(await stateDir(dir, 't'))).toBe(true) + await runCli(['stop', '-C', dir, '--clear']) + expect((await runCli(['stop', '-C', dir, '-tag', 't', '-force'])).code).toBe(0) + expect(await forceRequested(await stateDir(dir, 't'))).toBe(true) + }) + it('warns a later plain stop that a forced stop from earlier is still pending', async () => { // stop --force issued while nothing was running leaves the marker sticky // — it aborts the NEXT eval, not this session's. A human who later runs