Skip to content

Commit 735ffb4

Browse files
committed
fix: detect ambiguous positional arguments provided both positionally and as named options
1 parent 16ede0b commit 735ffb4

5 files changed

Lines changed: 58 additions & 14 deletions

File tree

.changeset/positional-ambiguity.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'padrone': patch
3+
---
4+
5+
Detect ambiguous positional arguments provided both positionally and as named options. For example, `cmd val --pos1=val` with `positional: ['pos1', 'pos2']` now reports a validation error instead of silently overwriting.

packages/padrone/src/core/exec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,8 @@ export function execCommand(
189189
};
190190

191191
const coreValidate = (validateCtx: InterceptorValidateContext): InterceptorValidateResult | Promise<InterceptorValidateResult> => {
192-
const preprocessedArgs = buildCommandArgs(validateCtx.command, validateCtx.rawArgs, validateCtx.positionalArgs);
192+
const { args: preprocessedArgs, issues } = buildCommandArgs(validateCtx.command, validateCtx.rawArgs, validateCtx.positionalArgs);
193+
if (issues) return { args: undefined, argsResult: { issues } as any };
193194
const validated = validateCommandArgs(validateCtx.command, preprocessedArgs);
194195
return thenMaybe(validated, (v) => v as InterceptorValidateResult);
195196
};

packages/padrone/src/core/validate.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,9 @@ export function buildCommandArgs(
142142
command: AnyPadroneCommand,
143143
rawArgs: Record<string, unknown>,
144144
positionalArgs: string[],
145-
): Record<string, unknown> {
145+
): { args: Record<string, unknown>; issues?: StandardSchemaV1.Issue[] } {
146146
let preprocessedArgs = preprocessArgs(rawArgs, { flags: {}, aliases: {} });
147+
let issues: StandardSchemaV1.Issue[] | undefined;
147148

148149
const positionalConfig = command.meta?.positional ? parsePositionalConfig(command.meta.positional) : [];
149150

@@ -153,6 +154,13 @@ export function buildCommandArgs(
153154
const { name, variadic } = positionalConfig[i]!;
154155
if (argIndex >= positionalArgs.length) break;
155156

157+
// Detect ambiguity: same arg provided both positionally and as a named option
158+
if (name in preprocessedArgs) {
159+
issues ??= [];
160+
issues.push({ path: [name], message: `Ambiguous argument "${name}": provided both positionally and as a named option` });
161+
continue;
162+
}
163+
156164
if (variadic) {
157165
const remainingPositionals = positionalConfig.slice(i + 1);
158166
const nonVariadicAfter = remainingPositionals.filter((p) => !p.variadic).length;
@@ -173,7 +181,7 @@ export function buildCommandArgs(
173181
preprocessedArgs = coerceArgs(preprocessedArgs, command.argsSchema);
174182
}
175183

176-
return preprocessedArgs;
184+
return { args: preprocessedArgs, issues };
177185
}
178186

179187
/**
@@ -251,7 +259,8 @@ export function coreValidateForParse(
251259
rawArgs: Record<string, unknown>,
252260
positionalArgs: string[],
253261
): InterceptorValidateResult | Promise<InterceptorValidateResult> {
254-
const preprocessedArgs = buildCommandArgs(command, rawArgs, positionalArgs);
262+
const { args: preprocessedArgs, issues } = buildCommandArgs(command, rawArgs, positionalArgs);
263+
if (issues) return { args: undefined, argsResult: { issues } as any };
255264
const validated = validateCommandArgs(command, preprocessedArgs);
256265
return thenMaybe(validated, (v) => v as InterceptorValidateResult);
257266
}

packages/padrone/src/extension/interactive.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ const interactiveInterceptor = defineInterceptor({ id: 'padrone:interactive', na
3737
if (!willPrompt) return next();
3838

3939
// Preprocess args to determine what's missing
40-
const preprocessedArgs = buildCommandArgs(command, ctx.rawArgs, ctx.positionalArgs);
40+
const { args: preprocessedArgs, issues: positionalIssues } = buildCommandArgs(command, ctx.rawArgs, ctx.positionalArgs);
41+
if (positionalIssues) return { args: undefined, argsResult: { issues: positionalIssues } } as any;
4142

4243
// Check for unknown args before prompting
4344
const unknowns = checkUnknownArgs(command, preprocessedArgs);

packages/padrone/tests/positional-as-named.test.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,26 @@ describe('Positional arguments passed as named options', () => {
5656
c.arguments(z.object({ pos1: z.string(), pos2: z.string() }), { positional: ['pos1', 'pos2'] }).action((args) => args),
5757
);
5858

59-
it('should not correctly resolve when first is named and second is positional', () => {
60-
// `cmd val2 --pos1=val1` — val2 is assigned to pos1 positionally (overwriting --pos1=val1), pos2 is never set
59+
it('should fail with ambiguity error when first is named and second is positional', () => {
60+
// `cmd val2 --pos1=val1` — pos1 is provided both positionally and as --pos1
6161
const result = program.eval('cmd val2 --pos1=val1');
62-
// The positional assignment overwrites the named value, so pos1 becomes val2
63-
// and pos2 is missing, causing a validation error
6462
expect(result.args).toBeUndefined();
6563
expect(result.argsResult?.issues).toBeDefined();
66-
expect(result.argsResult?.issues?.some((i: any) => i.path?.includes('pos2'))).toBe(true);
64+
expect(result.argsResult?.issues?.some((i: any) => i.message?.includes('Ambiguous'))).toBe(true);
65+
});
66+
});
67+
68+
describe('two positionals with optional — ambiguity still detected', () => {
69+
const program = createPadrone('app').command('cmd', (c) =>
70+
c.arguments(z.object({ pos1: z.string(), pos2: z.string().optional() }), { positional: ['pos1', 'pos2'] }).action((args) => args),
71+
);
72+
73+
it('should fail even when the trailing positional is optional', () => {
74+
// `cmd val2 --pos1=val1` — pos1 is provided both positionally and as --pos1
75+
const result = program.eval('cmd val2 --pos1=val1');
76+
expect(result.args).toBeUndefined();
77+
expect(result.argsResult?.issues).toBeDefined();
78+
expect(result.argsResult?.issues?.some((i: any) => i.message?.includes('Ambiguous'))).toBe(true);
6779
});
6880
});
6981

@@ -88,19 +100,35 @@ describe('Positional arguments passed as named options', () => {
88100
});
89101

90102
it('should fail when naming a middle positional but passing the last by position', () => {
91-
// `cmd val1 val3 --b=val2` — val1→a, val3→b (overwrites --b=val2), c is missing
103+
// `cmd val1 val3 --b=val2` — b is provided both positionally and as --b
92104
const result = program.eval('cmd val1 val3 --b=val2');
93105
expect(result.args).toBeUndefined();
94106
expect(result.argsResult?.issues).toBeDefined();
95-
expect(result.argsResult?.issues?.some((i: any) => i.path?.includes('c'))).toBe(true);
107+
expect(result.argsResult?.issues?.some((i: any) => i.message?.includes('Ambiguous'))).toBe(true);
96108
});
97109

98110
it('should fail when naming the first positional but passing later ones by position', () => {
99-
// `cmd val2 val3 --a=val1` — val2→a (overwrites), val3→b, c is missing
111+
// `cmd val2 val3 --a=val1` — a is provided both positionally and as --a
100112
const result = program.eval('cmd val2 val3 --a=val1');
101113
expect(result.args).toBeUndefined();
102114
expect(result.argsResult?.issues).toBeDefined();
103-
expect(result.argsResult?.issues?.some((i: any) => i.path?.includes('c'))).toBe(true);
115+
expect(result.argsResult?.issues?.some((i: any) => i.message?.includes('Ambiguous'))).toBe(true);
116+
});
117+
});
118+
119+
describe('three positionals with optional last — naming earlier still fails', () => {
120+
const program = createPadrone('app').command('cmd', (c) =>
121+
c
122+
.arguments(z.object({ a: z.string(), b: z.string(), c: z.string().optional() }), { positional: ['a', 'b', 'c'] })
123+
.action((args) => args),
124+
);
125+
126+
it('should fail when naming first positional with positional values present, even if last is optional', () => {
127+
// `cmd val1 val2 --a=val3` — a is provided both positionally and as --a
128+
const result = program.eval('cmd val1 val2 --a=val3');
129+
expect(result.args).toBeUndefined();
130+
expect(result.argsResult?.issues).toBeDefined();
131+
expect(result.argsResult?.issues?.some((i: any) => i.message?.includes('Ambiguous'))).toBe(true);
104132
});
105133
});
106134
});

0 commit comments

Comments
 (0)