fix(cjs): accept dotted canonical command form (#3243) (#3248)

* test: reproduce CJS dispatcher rejecting dotted form (#3243)

runGsdTools assertions confirm that generate-slug.hello-world,
current-timestamp.date, validate.plan, roadmap.analyze, phases.list, and
check.decision-coverage-plan all fail with "Unknown command: <dotted>" —
the dispatcher switch only accepts the spaced form.

Edge cases (no dots unchanged, leading-dot rejected, unknown dotted form
suggests spaced equivalent) are also specified; those three pass already
because the shim is not yet implemented.

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

* fix(cjs): accept dotted canonical command form (#3243)

Add a shim at the top of main() in gsd-tools.cjs that splits args[0] on
the first dot when present, normalizing "state.update" → command='state'
args=['state','update',...] before the switch statement is reached.

Any caller that bypasses the SDK (stale npm-installed binary, workflow
shell-out, third-party script) can now use the canonical dotted form
natively without hitting "Unknown command: <domain>.<subcommand>".

The shim guards against empty head/rest so ".hidden" and bare "." args
are unchanged and fall through to the existing "Unknown command" path.

Also improves the default "Unknown command" error message to suggest
the spaced equivalent when a dotted form was passed — e.g. for "foo.bar"
the error now reads: Unknown command: foo — did you mean: "foo bar"?

Parallel to dottedCommandToCjsArgv in sdk/src/query/query-fallback-bridge-adapter.ts;
intentionally kept separate to avoid SDK coupling.

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

* changeset: pr=3248 for #3243

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

* test: tighten dotted-form suggestion assertion (CR nitpick)

* fix(cjs): suggestion uses first-dot split (CR finding 1, multi-dot consistency)

The "did you mean" hint in the Unknown-command default case was replacing ALL
dots with spaces (state.update.foo → "state update foo"), but the dispatcher
shim only splits on the FIRST dot (state.update.foo → head=state, rest=update.foo).
Apply CR's exact patch to use indexOf+slice so suggestion matches dispatch
behavior. Add a multi-dot regression test (a.b.c must suggest "a b.c", not
"a b c").

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-08 08:40:36 -04:00
committed by GitHub
parent b2f0fdf250
commit 97bde8615f
3 changed files with 235 additions and 7 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3248
---
**`gsd-tools <domain>.<subcommand>` (dotted form) now accepted natively by the CJS dispatcher** — previously only worked when invoked via the SDK, which split the form client-side. Stale SDK binaries and direct CJS callers no longer hit "Unknown command" on the canonical form.

View File

@@ -241,7 +241,7 @@ function parseMultiwordArg(args, flag) {
// ─── CLI Router ───────────────────────────────────────────────────────────────
async function main() {
const args = process.argv.slice(2);
let args = process.argv.slice(2);
// Optional cwd override for sandboxed subagents running outside project root.
let cwd = process.cwd();
@@ -339,7 +339,30 @@ async function main() {
args.splice(defaultIdx, 2);
}
const command = args[0];
let command = args[0];
// #3243: accept dotted canonical form (e.g. `state.update`) as well as the
// spaced form (`state update`). Workflow files and stale SDK binaries pass
// the dotted canonical form directly; any caller that bypasses the SDK
// client-side split hit "Unknown command" before this shim.
//
// Split on the FIRST dot only — `check.decision-coverage-plan` becomes
// command='check', args=['check','decision-coverage-plan',...rest].
// Parallel to dottedCommandToCjsArgv in sdk/src/query/query-fallback-bridge-adapter.ts;
// kept separate here to avoid SDK coupling (see TODO: extract to shared helper).
//
// Guard: head and rest must both be non-empty (rejects leading-dot args like
// ".hidden" and bare-dot ".").
const originalCommand = command; // preserved for "Unknown command" suggestion
if (typeof command === 'string' && command.includes('.')) {
const dotIdx = command.indexOf('.');
const head = command.slice(0, dotIdx);
const rest = command.slice(dotIdx + 1);
if (head && rest) {
command = head;
args = [head, rest, ...args.slice(1)];
}
}
// Top-level usage string — emitted by `gsd-tools` (no args) and by
// `gsd-tools --help` / any `--help` request below.
@@ -424,7 +447,7 @@ async function main() {
}
};
try {
await runCommand(command, args, cwd, raw, defaultValue);
await runCommand(command, args, cwd, raw, defaultValue, originalCommand);
cleanup();
} catch (e) {
fs.writeSync = origWriteSync;
@@ -445,7 +468,7 @@ async function main() {
return origWriteSync2.call(fs, fd, data, ...rest);
};
try {
await runCommand(command, args, cwd, raw, defaultValue);
await runCommand(command, args, cwd, raw, defaultValue, originalCommand);
} finally {
fs.writeSync = origWriteSync2;
}
@@ -479,7 +502,7 @@ function extractField(obj, fieldPath) {
return current;
}
async function runCommand(command, args, cwd, raw, defaultValue) {
async function runCommand(command, args, cwd, raw, defaultValue, originalCommand) {
switch (command) {
case 'state': {
routeStateCommand({
@@ -1163,8 +1186,25 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
break;
}
default:
error(`Unknown command: ${command}`);
default: {
// #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim
// above split it so `command` here is the head ("foo"). Use
// originalCommand to reconstruct the original dotted form and suggest
// the spaced equivalent — surfacing a useful diagnostic instead of just
// "Unknown command: foo".
const wasDotted =
typeof originalCommand === 'string' &&
originalCommand !== command &&
originalCommand.includes('.');
let suggestion = '';
if (wasDotted) {
const dotIdx = originalCommand.indexOf('.');
const head = originalCommand.slice(0, dotIdx);
const rest = originalCommand.slice(dotIdx + 1);
suggestion = ` — did you mean: "${head} ${rest}"?`;
}
error(`Unknown command: ${command}${suggestion}`);
}
}
}

View File

@@ -0,0 +1,183 @@
/**
* Regression tests for bug #3243.
*
* The CJS dispatcher (gsd-tools.cjs) must accept dotted canonical command
* form (e.g. `state.update`) as well as the spaced form (`state update`).
* Workflow markdown files emit `gsd-sdk query <domain>.<subcommand>` calls,
* and any caller that bypasses the SDK (stale npm binary, direct shell-out,
* third-party script) would hit "Unknown command: <domain>.<subcommand>".
*
* The fix: a top-of-main() shim that splits args[0] on the first `.` when
* present and normalizes to the spaced form before the switch is reached.
*
* This test file uses runGsdTools() — never readFileSync + .includes().
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('bug #3243: CJS dispatcher accepts dotted canonical command form', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── generate-slug: no project structure needed, deterministic output ────
test('generate-slug.hello-world (dotted) produces the same slug as spaced form', () => {
const spaced = runGsdTools(['generate-slug', 'hello-world'], tmpDir);
assert.strictEqual(spaced.success, true, [
'control (spaced form) failed:',
spaced.error,
].join(' '));
const dotted = runGsdTools(['generate-slug.hello-world'], tmpDir);
// Before the fix this errors: "Unknown command: generate-slug.hello-world"
assert.strictEqual(dotted.success, true, [
'dotted form must not emit "Unknown command":',
dotted.error,
].join(' '));
assert.strictEqual(dotted.output, spaced.output,
'dotted form must produce identical output to spaced form');
});
test('current-timestamp.date (dotted) produces the same output as spaced form', () => {
const spaced = runGsdTools(['current-timestamp', 'date'], tmpDir);
assert.strictEqual(spaced.success, true, [
'control (spaced form) failed:',
spaced.error,
].join(' '));
const dotted = runGsdTools(['current-timestamp.date'], tmpDir);
assert.strictEqual(dotted.success, true, [
'dotted form must not emit "Unknown command":',
dotted.error,
].join(' '));
assert.strictEqual(dotted.output, spaced.output,
'dotted form must produce identical output to spaced form');
});
// ── Commands with subcommands that need a project ────────────────────────
test('validate.plan (dotted) routes into validate handler, not "Unknown command"', () => {
const dotted = runGsdTools(['validate.plan'], tmpDir);
// Before the fix: success=false, error contains "Unknown command: validate.plan"
// After the fix: success=false is still possible (validate needs a PLAN.md),
// but the error must NOT mention "Unknown command".
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: validate.plan'),
[
'dotted form must not produce "Unknown command: validate.plan".',
'Got error:', errText,
].join('\n')
);
});
test('roadmap.analyze (dotted) routes into roadmap handler, not "Unknown command"', () => {
const dotted = runGsdTools(['roadmap.analyze'], tmpDir);
// success=true means it reached the handler (even if handler reports no ROADMAP.md).
// success=false means dispatcher rejected it — assert the error is NOT "Unknown command".
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: roadmap.analyze'),
[
'dotted form must not produce "Unknown command: roadmap.analyze".',
'Got error:', errText,
].join('\n')
);
});
test('phases.list (dotted) routes into phases handler, not "Unknown command"', () => {
const dotted = runGsdTools(['phases.list'], tmpDir);
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: phases.list'),
[
'dotted form must not produce "Unknown command: phases.list".',
'Got error:', errText,
].join('\n')
);
});
// ── Multi-dot commands: split on first dot only ──────────────────────────
test('check.decision-coverage-plan (multi-dot-safe: first dot splits)', () => {
const dotted = runGsdTools(['check.decision-coverage-plan'], tmpDir);
// "check" is not a known top-level command currently, so this will still
// fail — but the error must NOT say "Unknown command: check.decision-coverage-plan"
// (the dotted form); it should say something about "check" (the split result).
const errText = dotted.error || '';
assert.ok(
!errText.includes('Unknown command: check.decision-coverage-plan'),
[
'multi-dot dotted form must not be passed verbatim to "Unknown command".',
'Got error:', errText,
].join('\n')
);
});
// ── Edge cases ────────────────────────────────────────────────────────────
test('command without dots is unchanged (existing behaviour preserved)', () => {
const result = runGsdTools(['generate-slug', 'no-dots-here'], tmpDir);
assert.strictEqual(result.success, true, [
'spaced-only invocation must still work:',
result.error,
].join(' '));
assert.ok(result.output.length > 0, 'output must be non-empty');
});
test('leading-dot arg (e.g. .hidden) is not mis-routed by the shim', () => {
// A leading dot in args[0] like ".hidden" has head="" (empty) after split,
// so the shim must reject it and fall through to the existing "Unknown command"
// path (not silently reroute to an empty-string command).
const result = runGsdTools(['.hidden'], tmpDir);
assert.strictEqual(result.success, false, 'leading-dot arg must not succeed');
});
// ── "Unknown command" error message improvement ──────────────────────────
test('"Unknown command" error for dotted form suggests spaced equivalent', () => {
// A genuinely unknown dotted command (e.g. "foo.bar") should include a
// "did you mean" hint pointing at the spaced form "foo bar".
const result = runGsdTools(['foo.bar'], tmpDir);
assert.strictEqual(result.success, false, '"foo.bar" must fail');
assert.ok(
result.error.includes('foo bar'),
[
'error for unknown dotted command should suggest spaced form "foo bar".',
'Got:', result.error,
].join('\n')
);
});
test('multi-dot unknown command suggestion uses first-dot split only (a.b.c → "a b.c")', () => {
// The shim splits only on the FIRST dot, so the suggestion must mirror that:
// "a.b.c" → head="a", rest="b.c" → suggest "a b.c", NOT "a b c".
const result = runGsdTools(['a.b.c'], tmpDir);
assert.strictEqual(result.success, false, '"a.b.c" must fail');
assert.ok(
result.error.includes('a b.c'),
[
'suggestion for multi-dot unknown command must use first-dot split: "a b.c".',
'Got:', result.error,
].join('\n')
);
assert.ok(
!result.error.includes('a b c'),
[
'suggestion must NOT replace all dots ("a b c" is wrong — only first dot splits).',
'Got:', result.error,
].join('\n')
);
});
});