From e723fa12fede711cc9920b261f512c34c26b6ac5 Mon Sep 17 00:00:00 2001 From: Gal Be Date: Tue, 15 Sep 2026 11:27:42 +0300 Subject: [PATCH 1/2] Derive the single-dash flags from the options they are parsed with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's agent prompt says "I may run `autor3search-javascript stop -force` myself". That failed with "Unknown option '-f'". parseArgs takes a single dash only for a single-character name, so the harness rewrites the documented spellings first — but the rewrite was driven by a list of names written out by hand next to the declaration, and that list had drifted: `stop` passed ['tag'] and never mentioned force. `baseline -force` was broken the same way, and nothing had noticed either. Passing the parseArgs options themselves removes the gap a hand-kept list leaves open. A flag can no longer be documented, declared, and still unparseable, because the thing that expands it and the thing that parses it are now one object. Consuming the following token is decided by the declared type rather than assumed. Booleans take no value, so the token after one is another flag and must still be expanded: adding 'force' to the old list would have made `stop -force -tag t` fail on "Unknown option '-t'" instead, trading the bug for a quieter one. Single-character names are left alone, since parseArgs already accepts `-C`. Worth knowing: -force was inert in an agent's hands before this, because it simply errored. It works now, so program.md's standing rule against passing it is load-bearing where it previously guarded nothing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FV5Usy3xLiz4ngEp6zPC5L --- src/cli/cmd-baseline.js | 13 +++++++------ src/cli/cmd-eval.js | 15 ++++++++------- src/cli/cmd-status.js | 5 +++-- src/cli/cmd-stop.js | 15 ++++++++------- src/cli/context.js | 27 ++++++++++++++++++++++----- test/cli/context.test.js | 36 ++++++++++++++++++++++++++++++++---- test/cli/stop.test.js | 22 ++++++++++++++++++++++ 7 files changed, 102 insertions(+), 31 deletions(-) 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 From 268d9a33bd194481e54558b7fcd655f17062db8e Mon Sep 17 00:00:00 2001 From: Gal Be Date: Tue, 15 Sep 2026 11:27:53 +0300 Subject: [PATCH 2/2] Release 0.2.3 `stop -force`, which the README's agent prompt tells you to run, failed with "Unknown option '-f'" in 0.2.2 and every release before it. So did `baseline -force`. Both parse now, and the single-dash spellings are derived from the option declarations rather than a list kept by hand beside them, so a documented flag cannot go unparseable again without a test noticing. No behaviour changes anywhere else. Windows support and the marker-based stop path shipped in 0.2.2 are untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FV5Usy3xLiz4ngEp6zPC5L --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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",