Files
msd-core/sdk/src/query/registry.ts
Tom Boucher 5676e2e4ef fix(sdk): forward --ws workstream flag through query dispatch (#2546)
* fix(sdk): forward --ws workstream flag through query dispatch (closes #2524)

- cli.ts: pass args.ws as workstream to registry.dispatch()
- registry.ts: add workstream? param to dispatch(), thread to handler
- utils.ts: add optional workstream? to QueryHandler type signature
- helpers.ts: planningPaths() accepts workstream? and uses relPlanningPath()
- All ~26 query handlers updated to receive and pass workstream to planningPaths()
- Config/commit/intel handlers use _workstream (project-global, not scoped)
- Add failing-then-passing test: tests/bug-2524-sdk-query-ws-flag.test.cjs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): forward workstream to all downstream query helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): rewrite #2524 test as static source assertions — no sdk/dist build in CI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:33:24 -04:00

176 lines
6.0 KiB
TypeScript

/**
* Query command registry — routes commands to native SDK handlers.
*
* The registry is a flat `Map<string, QueryHandler>` that maps command names
* to handler functions. Unknown keys passed to `dispatch()` throw `GSDError`.
* The `gsd-sdk query` CLI resolves argv with `resolveQueryArgv()` before dispatch;
* there is no automatic delegation to `gsd-tools.cjs`.
*
* Also exports `extractField` — a TypeScript port of the `--pick` field
* extraction logic from gsd-tools.cjs (lines 365-382).
*
* @example
* ```typescript
* import { QueryRegistry, extractField } from './registry.js';
*
* const registry = new QueryRegistry();
* registry.register('generate-slug', generateSlug);
* const result = await registry.dispatch('generate-slug', ['My Phase'], '/project');
* const slug = extractField(result.data, 'slug'); // 'my-phase'
* ```
*/
import type { QueryResult, QueryHandler } from './utils.js';
import { GSDError, ErrorClassification } from '../errors.js';
// ─── extractField ──────────────────────────────────────────────────────────
/**
* Extract a nested field from an object using dot-notation and bracket syntax.
*
* Direct port of `extractField()` from gsd-tools.cjs (lines 365-382).
* Supports `a.b.c` dot paths, `items[0]` array indexing, and `items[-1]`
* negative indexing.
*
* @param obj - The object to extract from
* @param fieldPath - Dot-separated path with optional bracket notation
* @returns The extracted value, or undefined if the path doesn't resolve
*/
export function extractField(obj: unknown, fieldPath: string): unknown {
const parts = fieldPath.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
const bracketMatch = part.match(/^(.+?)\[(-?\d+)]$/);
if (bracketMatch) {
const key = bracketMatch[1];
const index = parseInt(bracketMatch[2], 10);
current = (current as Record<string, unknown>)[key];
if (!Array.isArray(current)) return undefined;
current = index < 0 ? current[current.length + index] : current[index];
} else {
current = (current as Record<string, unknown>)[part];
}
}
return current;
}
// ─── QueryRegistry ─────────────────────────────────────────────────────────
/**
* Flat command registry that routes query commands to native handlers.
*
* `dispatch()` throws `GSDError` for unknown command keys. The `gsd-sdk query`
* CLI uses `resolveQueryArgv()` first; when no handler matches, it may shell out
* to `gsd-tools.cjs` (see `cli.ts` and `QUERY-HANDLERS.md` fallback policy).
*/
export class QueryRegistry {
private handlers = new Map<string, QueryHandler>();
/**
* Register a native handler for a command name.
*
* @param command - The command name (e.g., 'generate-slug', 'state.load')
* @param handler - The handler function to invoke
*/
register(command: string, handler: QueryHandler): void {
this.handlers.set(command, handler);
}
/**
* Check if a command has a registered native handler.
*
* @param command - The command name to check
* @returns True if the command has a native handler
*/
has(command: string): boolean {
return this.handlers.has(command);
}
/**
* List all registered command names (for tooling, pipelines, and tests).
*/
commands(): string[] {
return Array.from(this.handlers.keys());
}
/**
* Get the handler for a command without dispatching.
*
* @param command - The command name to look up
* @returns The handler function, or undefined if not registered
*/
getHandler(command: string): QueryHandler | undefined {
return this.handlers.get(command);
}
/**
* Dispatch a command to its registered native handler.
*
* @param command - The command name to dispatch
* @param args - Arguments to pass to the handler
* @param projectDir - The project directory for context
* @param workstream - Optional workstream name to scope .planning paths
* @returns The query result from the handler
* @throws GSDError if no handler is registered for the command
*/
async dispatch(command: string, args: string[], projectDir: string, workstream?: string): Promise<QueryResult> {
const handler = this.handlers.get(command);
if (!handler) {
throw new GSDError(
`Unknown command: "${command}". No native handler registered.`,
ErrorClassification.Validation,
);
}
return handler(args, projectDir, workstream);
}
}
function expandSingleDottedToken(tokens: string[]): string[] {
if (tokens.length !== 1 || tokens[0].startsWith('--')) {
return tokens;
}
const t = tokens[0];
if (!t.includes('.')) {
return tokens;
}
return t.split('.');
}
function matchRegisteredPrefix(
tokens: string[],
registry: QueryRegistry,
): { cmd: string; args: string[] } | null {
for (let i = tokens.length; i >= 1; i--) {
const head = tokens.slice(0, i);
const dotted = head.join('.');
const spaced = head.join(' ');
if (registry.has(dotted)) {
return { cmd: dotted, args: tokens.slice(i) };
}
if (registry.has(spaced)) {
return { cmd: spaced, args: tokens.slice(i) };
}
}
return null;
}
/**
* Map argv after `gsd-sdk query` to a registered handler key and remaining args.
* Longest-prefix match on dotted (`a.b.c`) and spaced (`a b c`) keys; if no match,
* expands a single dotted token (`state.validate` → `state`, `validate`) and retries.
*/
export function resolveQueryArgv(
tokens: string[],
registry: QueryRegistry,
): { cmd: string; args: string[] } | null {
let matched = matchRegisteredPrefix(tokens, registry);
if (!matched) {
const expanded = expandSingleDottedToken(tokens);
if (expanded !== tokens) {
matched = matchRegisteredPrefix(expanded, registry);
}
}
return matched;
}