fix(#2597): expand dotted query tokens with trailing args (#2599)

resolveQueryArgv only expanded `init.execute-phase` → `init execute-phase`
when the tokens array had length 1. Argv like `init.execute-phase 1` has
length 2, skipped the expansion, and resolved to no registered handler.

All 50+ workflow files use the dotted form with arguments, so this broke
every non-argless query route (`init.execute-phase`, `state.update`,
`phase.add`, `milestone.complete`, etc.) at runtime.

Rename `expandSingleDottedToken` → `expandFirstDottedToken`: split only
the first token on its dots (guarding against `--` flags) and preserve
the tail as positional args. Identity comparison at the call site still
detects "no expansion" since we return the input array unchanged.

Adds regression tests for the three failure patterns reported:
`init.execute-phase 1`, `state.update status X`, `phase.add desc`.

Closes #2597
This commit is contained in:
Tom Boucher
2026-04-22 17:30:08 -04:00
committed by GitHub
parent b35fdd51f3
commit 1f2850c1a8
2 changed files with 46 additions and 6 deletions

View File

@@ -178,4 +178,31 @@ describe('resolveQueryArgv', () => {
args: [],
});
});
// Regression: #2597 — dotted command token followed by positional args.
// Before the fix, argv like ['init.execute-phase', '1'] returned null because
// expansion only ran for single-token input.
it('matches a dotted command token when positional args follow (#2597)', () => {
const registry = createRegistry();
expect(resolveQueryArgv(['init.execute-phase', '1'], registry)).toEqual({
cmd: 'init.execute-phase',
args: ['1'],
});
});
it('matches dotted state.update with trailing args (#2597)', () => {
const registry = createRegistry();
expect(resolveQueryArgv(['state.update', 'status', 'X'], registry)).toEqual({
cmd: 'state.update',
args: ['status', 'X'],
});
});
it('matches dotted phase.add with trailing args (#2597)', () => {
const registry = createRegistry();
expect(resolveQueryArgv(['phase.add', 'desc'], registry)).toEqual({
cmd: 'phase.add',
args: ['desc'],
});
});
});

View File

@@ -126,15 +126,28 @@ export class QueryRegistry {
}
}
function expandSingleDottedToken(tokens: string[]): string[] {
if (tokens.length !== 1 || tokens[0].startsWith('--')) {
/**
* If the first token contains a dot (e.g. `init.execute-phase`), split it into
* segments and prepend those segments in place of the original token. Args that
* follow the dotted token are preserved.
*
* Examples:
* ['init.new-project'] -> ['init', 'new-project']
* ['init.execute-phase', '1'] -> ['init', 'execute-phase', '1']
* ['state.update', 'status', 'X'] -> ['state', 'update', 'status', 'X']
*
* Returns the original array (by reference) when no expansion applies so callers
* can detect "nothing changed" via identity comparison.
*/
function expandFirstDottedToken(tokens: string[]): string[] {
if (tokens.length === 0) {
return tokens;
}
const t = tokens[0];
if (!t.includes('.')) {
const first = tokens[0];
if (first.startsWith('--') || !first.includes('.')) {
return tokens;
}
return t.split('.');
return [...first.split('.'), ...tokens.slice(1)];
}
function matchRegisteredPrefix(
@@ -166,7 +179,7 @@ export function resolveQueryArgv(
): { cmd: string; args: string[] } | null {
let matched = matchRegisteredPrefix(tokens, registry);
if (!matched) {
const expanded = expandSingleDottedToken(tokens);
const expanded = expandFirstDottedToken(tokens);
if (expanded !== tokens) {
matched = matchRegisteredPrefix(expanded, registry);
}